Merge branch 'master' into omniauth

Conflicts:
Gemfile.lock
app/controllers/agents_controller.rb

Dominik Sander 10 years ago
parent
commit
b71ea25418
72 changed files with 5483 additions and 479 deletions
  1. 2 0
      .buildpacks
  2. 30 13
      Gemfile
  3. 140 83
      Gemfile.lock
  4. 5 5
      Procfile
  5. 9 10
      README.md
  6. 6 3
      app/assets/javascripts/application.js.coffee.erb
  7. 6 0
      app/assets/stylesheets/application.css.scss.erb
  8. 21 0
      app/concerns/email_concern.rb
  9. 17 0
      app/concerns/liquid_droppable.rb
  10. 9 9
      app/concerns/liquid_interpolatable.rb
  11. 61 0
      app/concerns/web_request_concern.rb
  12. 7 7
      app/controllers/agents_controller.rb
  13. 8 0
      app/helpers/application_helper.rb
  14. 137 19
      app/helpers/dot_helper.rb
  15. 51 0
      app/models/agent.rb
  16. 1 1
      app/models/agents/data_output_agent.rb
  17. 9 4
      app/models/agents/email_agent.rb
  18. 10 4
      app/models/agents/email_digest_agent.rb
  19. 7 6
      app/models/agents/event_formatting_agent.rb
  20. 105 0
      app/models/agents/google_calendar_publish_agent.rb
  21. 2 2
      app/models/agents/growl_agent.rb
  22. 1 1
      app/models/agents/hipchat_agent.rb
  23. 149 47
      app/models/agents/imap_folder_agent.rb
  24. 1 1
      app/models/agents/jabber_agent.rb
  25. 2 2
      app/models/agents/mqtt_agent.rb
  26. 3 3
      app/models/agents/peak_detector_agent.rb
  27. 46 18
      app/models/agents/post_agent.rb
  28. 1 1
      app/models/agents/pushbullet_agent.rb
  29. 1 1
      app/models/agents/pushover_agent.rb
  30. 89 0
      app/models/agents/rss_agent.rb
  31. 2 2
      app/models/agents/shell_command_agent.rb
  32. 1 1
      app/models/agents/slack_agent.rb
  33. 1 1
      app/models/agents/translation_agent.rb
  34. 2 2
      app/models/agents/trigger_agent.rb
  35. 3 3
      app/models/agents/twilio_agent.rb
  36. 2 2
      app/models/agents/twitter_publish_agent.rb
  37. 24 73
      app/models/agents/website_agent.rb
  38. 2 2
      app/models/agents/weibo_publish_agent.rb
  39. 24 0
      app/models/event.rb
  40. 1 0
      app/views/agents/_form.html.erb
  41. 5 0
      app/views/agents/show.html.erb
  42. 160 0
      bin/setup_heroku
  43. 2 2
      config/initializers/delayed_job.rb
  44. 10 0
      config/initializers/silence_worker_status_logger.rb
  45. 25 0
      db/migrate/20140505201716_migrate_agents_to_liquid_templating.rb
  46. 21 0
      db/migrate/20140722131220_convert_efa_skip_agent.rb
  47. 30 0
      db/migrate/20140723110551_adopt_xpath_in_website_agent.rb
  48. 9 9
      db/seeds.rb
  49. 4 0
      deployment/heroku/Procfile.heroku
  50. 51 0
      deployment/heroku/unicorn.rb
  51. 67 0
      lib/google_calendar.rb
  52. 435 0
      spec/cassettes/Agents_GoogleCalendarPublishAgent/_receive/should_publish_any_payload_it_receives.yml
  53. 356 0
      spec/data_fixtures/github_rss.atom
  54. 2601 0
      spec/data_fixtures/google_calendar_api.json
  55. BIN
      spec/data_fixtures/private.key
  56. 4 4
      spec/fixtures/agents.yml
  57. 68 15
      spec/helpers/dot_helper_spec.rb
  58. 3 3
      spec/lib/utils_spec.rb
  59. 102 0
      spec/models/agent_spec.rb
  60. 3 2
      spec/models/agents/email_agent_spec.rb
  61. 3 1
      spec/models/agents/email_digest_agent_spec.rb
  62. 5 17
      spec/models/agents/event_formatting_agent_spec.rb
  63. 43 0
      spec/models/agents/google_calendar_publish_agent_spec.rb
  64. 34 18
      spec/models/agents/imap_folder_agent_spec.rb
  65. 74 17
      spec/models/agents/post_agent_spec.rb
  66. 81 0
      spec/models/agents/rss_agent_spec.rb
  67. 85 60
      spec/models/agents/website_agent_spec.rb
  68. 36 0
      spec/models/event_spec.rb
  69. 88 0
      spec/support/shared_examples/email_concern.rb
  70. 5 5
      spec/support/shared_examples/liquid_interpolatable.rb
  71. 66 0
      spec/support/shared_examples/web_request_concern.rb
  72. 9 0
      spec/support/vcr_support.rb

+ 2 - 0
.buildpacks

@@ -0,0 +1,2 @@
1
+https://github.com/cantino/heroku-selectable-procfile.git
2
+https://github.com/heroku/heroku-buildpack-ruby.git

+ 30 - 13
Gemfile

@@ -9,9 +9,9 @@ end
9 9
 
10 10
 gem 'bundler', '>= 1.5.0'
11 11
 
12
-gem 'protected_attributes', '~>1.0.7'
12
+gem 'protected_attributes', '~>1.0.8'
13 13
 
14
-gem 'rails', '4.1.1'
14
+gem 'rails' , '4.1.4'
15 15
 
16 16
 case RUBY_PLATFORM
17 17
 when /freebsd/
@@ -22,13 +22,13 @@ else
22 22
   gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw]
23 23
 end
24 24
 
25
-gem 'mysql2', '~> 0.3.15'
25
+gem 'mysql2', '~> 0.3.16'
26 26
 gem 'devise', '~> 3.2.4'
27
-gem 'kaminari', '~> 0.15.1'
28
-gem 'bootstrap-kaminari-views', '~> 0.0.2'
29
-gem 'rufus-scheduler', '~> 3.0.7', require: false
27
+gem 'kaminari', '~> 0.16.1'
28
+gem 'bootstrap-kaminari-views', '~> 0.0.3'
29
+gem 'rufus-scheduler', '~> 3.0.8', require: false
30 30
 gem 'json', '~> 1.8.1'
31
-gem 'jsonpath', '~> 0.5.3'
31
+gem 'jsonpath', '~> 0.5.6'
32 32
 gem 'twilio-ruby', '~> 3.11.5'
33 33
 gem 'ruby-growl', '~> 4.1.0'
34 34
 gem 'liquid', '~> 2.6.1'
@@ -64,16 +64,17 @@ gem 'wunderground', '~> 1.2.0'
64 64
 gem 'forecast_io', '~> 2.0.0'
65 65
 gem 'rturk', '~> 2.12.1'
66 66
 
67
+gem "google-api-client"
68
+
67 69
 gem 'twitter', '~> 5.8.0'
68
-gem 'twitter-stream', github: 'cantino/twitter-stream', branch: 'master'
70
+gem 'cantino-twitter-stream', github: 'cantino/twitter-stream', branch: 'master'
69 71
 gem 'em-http-request', '~> 1.1.2'
70 72
 gem 'weibo_2', '~> 0.1.4'
71 73
 gem 'hipchat', '~> 1.2.0'
72 74
 gem 'xmpp4r',  '~> 0.5.6'
75
+gem 'feed-normalizer'
73 76
 gem 'slack-notifier', '~> 0.5.0'
74
-
75 77
 gem 'therubyracer', '~> 0.12.1'
76
-
77 78
 gem 'mqtt'
78 79
 
79 80
 gem 'omniauth'
@@ -84,17 +85,19 @@ gem 'omniauth-github'
84 85
 group :development do
85 86
   gem 'binding_of_caller'
86 87
   gem 'better_errors'
88
+  gem 'quiet_assets'
87 89
 end
88 90
 
89 91
 group :development, :test do
92
+  gem 'vcr'
90 93
   gem 'dotenv-rails'
91 94
   gem 'pry'
92
-  gem 'rspec-rails'
93
-  gem 'rspec'
95
+  gem 'rspec-rails', '~> 2.14'
96
+  gem 'rspec', '~> 2.14'
94 97
   gem 'shoulda-matchers'
95 98
   gem 'rr'
96 99
   gem 'delorean'
97
-  gem 'webmock', require: false
100
+  gem 'webmock', '~> 1.17.4', require: false
98 101
   gem 'coveralls', require: false
99 102
 end
100 103
 
@@ -102,3 +105,17 @@ group :production do
102 105
   gem 'dotenv-deployment'
103 106
   gem 'rack'
104 107
 end
108
+
109
+# This hack needs some explanation.  When on Heroku, use the pg, unicorn, and rails12factor gems.
110
+# When not on Heroku, we still want our Gemfile.lock to include these gems, so we scope them to
111
+# an unsupported platform.
112
+if ENV['ON_HEROKU'] || ENV['HEROKU_POSTGRESQL_ROSE_URL'] || File.read(File.join(File.dirname(__FILE__), 'Procfile')) =~ /intended for Heroku/
113
+  gem 'pg'
114
+  gem 'unicorn'
115
+  gem 'rails_12factor'
116
+else
117
+  gem 'pg', platform: :ruby_18
118
+  gem 'unicorn', platform: :ruby_18
119
+  gem 'rails_12factor', platform: :ruby_18
120
+end
121
+

+ 140 - 83
Gemfile.lock

@@ -1,9 +1,9 @@
1 1
 GIT
2 2
   remote: git://github.com/cantino/twitter-stream.git
3
-  revision: fde6bed2b62ca487d49e4a57381bbfca6e33361b
3
+  revision: 1c60a1007c50476f23374a8aea796769a088ffe0
4 4
   branch: master
5 5
   specs:
6
-    twitter-stream (0.1.15)
6
+    cantino-twitter-stream (0.1.15)
7 7
       eventmachine (>= 0.12.8)
8 8
       http_parser.rb (~> 0.6.0)
9 9
       simple_oauth (~> 0.2.0)
@@ -12,27 +12,27 @@ GEM
12 12
   remote: https://rubygems.org/
13 13
   specs:
14 14
     ace-rails-ap (2.0.1)
15
-    actionmailer (4.1.1)
16
-      actionpack (= 4.1.1)
17
-      actionview (= 4.1.1)
15
+    actionmailer (4.1.4)
16
+      actionpack (= 4.1.4)
17
+      actionview (= 4.1.4)
18 18
       mail (~> 2.5.4)
19
-    actionpack (4.1.1)
20
-      actionview (= 4.1.1)
21
-      activesupport (= 4.1.1)
19
+    actionpack (4.1.4)
20
+      actionview (= 4.1.4)
21
+      activesupport (= 4.1.4)
22 22
       rack (~> 1.5.2)
23 23
       rack-test (~> 0.6.2)
24
-    actionview (4.1.1)
25
-      activesupport (= 4.1.1)
24
+    actionview (4.1.4)
25
+      activesupport (= 4.1.4)
26 26
       builder (~> 3.1)
27 27
       erubis (~> 2.7.0)
28
-    activemodel (4.1.1)
29
-      activesupport (= 4.1.1)
28
+    activemodel (4.1.4)
29
+      activesupport (= 4.1.4)
30 30
       builder (~> 3.1)
31
-    activerecord (4.1.1)
32
-      activemodel (= 4.1.1)
33
-      activesupport (= 4.1.1)
31
+    activerecord (4.1.4)
32
+      activemodel (= 4.1.4)
33
+      activesupport (= 4.1.4)
34 34
       arel (~> 5.0.0)
35
-    activesupport (4.1.1)
35
+    activesupport (4.1.4)
36 36
       i18n (~> 0.6, >= 0.6.9)
37 37
       json (~> 1.7, >= 1.7.7)
38 38
       minitest (~> 5.1)
@@ -40,6 +40,10 @@ GEM
40 40
       tzinfo (~> 1.1)
41 41
     addressable (2.3.6)
42 42
     arel (5.0.1.20140414130214)
43
+    autoparse (0.3.3)
44
+      addressable (>= 2.3.1)
45
+      extlib (>= 0.9.15)
46
+      multi_json (>= 1.0.0)
43 47
     bcrypt (3.1.7)
44 48
     better_errors (1.1.0)
45 49
       coderay (>= 1.0.0)
@@ -56,10 +60,10 @@ GEM
56 60
     coffee-rails (4.0.1)
57 61
       coffee-script (>= 2.2.0)
58 62
       railties (>= 4.0.0, < 5.0)
59
-    coffee-script (2.2.0)
63
+    coffee-script (2.3.0)
60 64
       coffee-script-source
61 65
       execjs
62
-    coffee-script-source (1.7.0)
66
+    coffee-script-source (1.7.1)
63 67
     cookiejar (0.3.2)
64 68
     coveralls (0.7.0)
65 69
       multi_json (~> 1.3)
@@ -71,7 +75,7 @@ GEM
71 75
       safe_yaml (~> 1.0.0)
72 76
     daemons (1.1.9)
73 77
     debug_inspector (0.0.2)
74
-    delayed_job (4.0.1)
78
+    delayed_job (4.0.2)
75 79
       activesupport (>= 3.0, < 4.2)
76 80
     delayed_job_active_record (4.0.1)
77 81
       activerecord (>= 3.0, < 4.2)
@@ -85,7 +89,7 @@ GEM
85 89
       thread_safe (~> 0.1)
86 90
       warden (~> 1.2.3)
87 91
     diff-lcs (1.2.5)
88
-    docile (1.1.3)
92
+    docile (1.1.5)
89 93
     dotenv (0.11.1)
90 94
       dotenv-deployment (~> 0.0.2)
91 95
     dotenv-deployment (0.0.2)
@@ -103,14 +107,18 @@ GEM
103 107
     erector (0.10.0)
104 108
       treetop (>= 1.2.3)
105 109
     erubis (2.7.0)
106
-    ethon (0.7.0)
110
+    ethon (0.7.1)
107 111
       ffi (>= 1.3.0)
108 112
     eventmachine (1.0.3)
109
-    execjs (2.0.2)
113
+    execjs (2.2.1)
114
+    extlib (0.9.16)
110 115
     faraday (0.9.0)
111 116
       multipart-post (>= 1.2, < 3)
112 117
     faraday_middleware (0.9.1)
113 118
       faraday (>= 0.7.4, < 0.10)
119
+    feed-normalizer (1.5.2)
120
+      hpricot (>= 0.6)
121
+      simple-rss (>= 1.1)
114 122
     ffi (1.9.3)
115 123
     forecast_io (2.0.0)
116 124
       faraday
@@ -124,29 +132,43 @@ GEM
124 132
     geokit-rails (2.0.1)
125 133
       geokit (~> 1.5)
126 134
       rails (>= 3.0)
135
+    google-api-client (0.7.1)
136
+      addressable (>= 2.3.2)
137
+      autoparse (>= 0.3.3)
138
+      extlib (>= 0.9.15)
139
+      faraday (>= 0.9.0)
140
+      jwt (>= 0.1.5)
141
+      launchy (>= 2.1.1)
142
+      multi_json (>= 1.0.0)
143
+      retriable (>= 1.4)
144
+      signet (>= 0.5.0)
145
+      uuidtools (>= 2.1.0)
127 146
     hashie (2.0.5)
128 147
     hike (1.2.3)
129 148
     hipchat (1.2.0)
130 149
       httparty
131
-    http (0.5.0)
150
+    hpricot (0.8.6)
151
+    http (0.5.1)
132 152
       http_parser.rb
133 153
     http_parser.rb (0.6.0)
134 154
     httparty (0.13.1)
135 155
       json (~> 1.8)
136 156
       multi_xml (>= 0.5.2)
137
-    i18n (0.6.9)
138
-    jquery-rails (3.1.0)
157
+    i18n (0.6.11)
158
+    jquery-rails (3.1.1)
139 159
       railties (>= 3.0, < 5.0)
140 160
       thor (>= 0.14, < 2.0)
141 161
     json (1.8.1)
142 162
     jsonpath (0.5.6)
143 163
       multi_json
144
-    jwt (0.1.11)
145
-      multi_json (>= 1.5)
146
-    kaminari (0.15.1)
164
+    jwt (1.0.0)
165
+    kaminari (0.16.1)
147 166
       actionpack (>= 3.0.0)
148 167
       activesupport (>= 3.0.0)
168
+    kgio (2.9.2)
149 169
     kramdown (1.3.3)
170
+    launchy (2.4.2)
171
+      addressable (~> 2.3)
150 172
     libv8 (3.16.14.3)
151 173
     liquid (2.6.1)
152 174
     macaddr (1.7.1)
@@ -159,24 +181,24 @@ GEM
159 181
     method_source (0.8.2)
160 182
     mime-types (1.25.1)
161 183
     mini_portile (0.6.0)
162
-    minitest (5.3.4)
184
+    minitest (5.4.0)
163 185
     mqtt (0.2.0)
164 186
     multi_json (1.10.1)
165 187
     multi_xml (0.5.5)
166 188
     multipart-post (2.0.0)
167 189
     mysql2 (0.3.16)
168 190
     naught (1.0.0)
169
-    nokogiri (1.6.2.1)
191
+    nokogiri (1.6.3.1)
170 192
       mini_portile (= 0.6.0)
171 193
     oauth (0.4.7)
172
-    oauth2 (0.9.3)
194
+    oauth2 (0.9.4)
173 195
       faraday (>= 0.8, < 0.10)
174
-      jwt (~> 0.1.8)
196
+      jwt (~> 1.0)
175 197
       multi_json (~> 1.3)
176 198
       multi_xml (~> 0.5)
177 199
       rack (~> 1.2)
178
-    omniauth (1.2.1)
179
-      hashie (>= 1.2, < 3)
200
+    omniauth (1.2.2)
201
+      hashie (>= 1.2, < 4)
180 202
       rack (~> 1.0)
181 203
     omniauth-37signals (1.0.5)
182 204
       omniauth (~> 1.0)
@@ -196,59 +218,75 @@ GEM
196 218
       multi_json (~> 1.3)
197 219
       omniauth-oauth (~> 1.0)
198 220
     orm_adapter (0.5.0)
221
+    pg (0.17.1)
199 222
     polyglot (0.3.5)
200
-    protected_attributes (1.0.7)
223
+    protected_attributes (1.0.8)
201 224
       activemodel (>= 4.0.1, < 5.0)
202
-    pry (0.9.12.6)
203
-      coderay (~> 1.0)
204
-      method_source (~> 0.8)
225
+    pry (0.10.0)
226
+      coderay (~> 1.1.0)
227
+      method_source (~> 0.8.1)
205 228
       slop (~> 3.4)
229
+    quiet_assets (1.0.3)
230
+      railties (>= 3.1, < 5.0)
206 231
     rack (1.5.2)
207 232
     rack-test (0.6.2)
208 233
       rack (>= 1.0)
209
-    rails (4.1.1)
210
-      actionmailer (= 4.1.1)
211
-      actionpack (= 4.1.1)
212
-      actionview (= 4.1.1)
213
-      activemodel (= 4.1.1)
214
-      activerecord (= 4.1.1)
215
-      activesupport (= 4.1.1)
234
+    rails (4.1.4)
235
+      actionmailer (= 4.1.4)
236
+      actionpack (= 4.1.4)
237
+      actionview (= 4.1.4)
238
+      activemodel (= 4.1.4)
239
+      activerecord (= 4.1.4)
240
+      activesupport (= 4.1.4)
216 241
       bundler (>= 1.3.0, < 2.0)
217
-      railties (= 4.1.1)
242
+      railties (= 4.1.4)
218 243
       sprockets-rails (~> 2.0)
219
-    railties (4.1.1)
220
-      actionpack (= 4.1.1)
221
-      activesupport (= 4.1.1)
244
+    rails_12factor (0.0.2)
245
+      rails_serve_static_assets
246
+      rails_stdout_logging
247
+    rails_serve_static_assets (0.0.2)
248
+    rails_stdout_logging (0.0.3)
249
+    railties (4.1.4)
250
+      actionpack (= 4.1.4)
251
+      activesupport (= 4.1.4)
222 252
       rake (>= 0.8.7)
223 253
       thor (>= 0.18.1, < 2.0)
254
+    raindrops (0.13.0)
224 255
     rake (10.3.2)
256
+    rdoc (4.1.1)
257
+      json (~> 1.4)
225 258
     ref (1.0.5)
226
-    rest-client (1.6.7)
227
-      mime-types (>= 1.16)
259
+    rest-client (1.6.8)
260
+      mime-types (~> 1.16)
261
+      rdoc (>= 2.4.2)
262
+    retriable (1.4.1)
228 263
     rr (1.1.2)
229
-    rspec (2.14.1)
230
-      rspec-core (~> 2.14.0)
231
-      rspec-expectations (~> 2.14.0)
232
-      rspec-mocks (~> 2.14.0)
233
-    rspec-core (2.14.8)
234
-    rspec-expectations (2.14.5)
264
+    rspec (2.99.0)
265
+      rspec-core (~> 2.99.0)
266
+      rspec-expectations (~> 2.99.0)
267
+      rspec-mocks (~> 2.99.0)
268
+    rspec-collection_matchers (1.0.0)
269
+      rspec-expectations (>= 2.99.0.beta1)
270
+    rspec-core (2.99.1)
271
+    rspec-expectations (2.99.2)
235 272
       diff-lcs (>= 1.1.3, < 2.0)
236
-    rspec-mocks (2.14.6)
237
-    rspec-rails (2.14.2)
273
+    rspec-mocks (2.99.2)
274
+    rspec-rails (2.99.0)
238 275
       actionpack (>= 3.0)
239 276
       activemodel (>= 3.0)
240 277
       activesupport (>= 3.0)
241 278
       railties (>= 3.0)
242
-      rspec-core (~> 2.14.0)
243
-      rspec-expectations (~> 2.14.0)
244
-      rspec-mocks (~> 2.14.0)
279
+      rspec-collection_matchers
280
+      rspec-core (~> 2.99.0)
281
+      rspec-expectations (~> 2.99.0)
282
+      rspec-mocks (~> 2.99.0)
245 283
     rturk (2.12.1)
246 284
       erector
247 285
       nokogiri
248 286
       rest-client
249 287
     ruby-growl (4.1)
250 288
       uuid (~> 2.3, >= 2.3.5)
251
-    rufus-scheduler (3.0.7)
289
+    rufus-scheduler (3.0.8)
252 290
       tzinfo
253 291
     safe_yaml (1.0.3)
254 292
     sass (3.2.19)
@@ -257,18 +295,24 @@ GEM
257 295
       sass (~> 3.2.0)
258 296
       sprockets (~> 2.8, <= 2.11.0)
259 297
       sprockets-rails (~> 2.0)
260
-    select2-rails (3.5.7)
298
+    select2-rails (3.5.9)
261 299
       thor (~> 0.14)
262
-    shoulda-matchers (2.6.0)
300
+    shoulda-matchers (2.6.2)
263 301
       activesupport (>= 3.0.0)
302
+    signet (0.5.1)
303
+      addressable (>= 2.2.3)
304
+      faraday (>= 0.9.0.rc5)
305
+      jwt (>= 0.1.5)
306
+      multi_json (>= 1.0.0)
307
+    simple-rss (1.3.1)
264 308
     simple_oauth (0.2.0)
265
-    simplecov (0.8.2)
309
+    simplecov (0.9.0)
266 310
       docile (~> 1.1.0)
267 311
       multi_json
268 312
       simplecov-html (~> 0.8.0)
269 313
     simplecov-html (0.8.0)
270 314
     slack-notifier (0.5.0)
271
-    slop (3.5.0)
315
+    slop (3.6.0)
272 316
     sprockets (2.11.0)
273 317
       hike (~> 1.2)
274 318
       multi_json (~> 1.0)
@@ -287,11 +331,11 @@ GEM
287 331
     thor (0.19.1)
288 332
     thread_safe (0.3.4)
289 333
     tilt (1.4.1)
290
-    tins (1.1.0)
334
+    tins (1.3.0)
291 335
     treetop (1.4.15)
292 336
       polyglot
293 337
       polyglot (>= 0.3.1)
294
-    twilio-ruby (3.11.5)
338
+    twilio-ruby (3.11.6)
295 339
       builder (>= 2.1.2)
296 340
       jwt (>= 0.1.2)
297 341
       multi_json (>= 1.3.0)
@@ -306,15 +350,21 @@ GEM
306 350
       memoizable (~> 0.4.0)
307 351
       naught (~> 1.0)
308 352
       simple_oauth (~> 0.2.0)
309
-    typhoeus (0.6.8)
310
-      ethon (>= 0.7.0)
353
+    typhoeus (0.6.9)
354
+      ethon (>= 0.7.1)
311 355
     tzinfo (1.2.1)
312 356
       thread_safe (~> 0.1)
313
-    uglifier (2.5.0)
357
+    uglifier (2.5.3)
314 358
       execjs (>= 0.3.0)
315 359
       json (>= 1.8.0)
360
+    unicorn (4.8.3)
361
+      kgio (~> 2.6)
362
+      rack
363
+      raindrops (~> 0.7)
316 364
     uuid (2.3.7)
317 365
       macaddr (~> 1.0)
366
+    uuidtools (2.1.4)
367
+    vcr (2.9.2)
318 368
     warden (1.2.3)
319 369
       rack (>= 1.0)
320 370
     webmock (1.17.4)
@@ -338,8 +388,9 @@ DEPENDENCIES
338 388
   ace-rails-ap (~> 2.0.1)
339 389
   better_errors
340 390
   binding_of_caller
341
-  bootstrap-kaminari-views (~> 0.0.2)
391
+  bootstrap-kaminari-views (~> 0.0.3)
342 392
   bundler (>= 1.5.0)
393
+  cantino-twitter-stream!
343 394
   coffee-rails (~> 4.0.0)
344 395
   coveralls
345 396
   daemons (~> 1.1.9)
@@ -352,34 +403,39 @@ DEPENDENCIES
352 403
   em-http-request (~> 1.1.2)
353 404
   faraday (~> 0.9.0)
354 405
   faraday_middleware
406
+  feed-normalizer
355 407
   forecast_io (~> 2.0.0)
356 408
   foreman (~> 0.63.0)
357 409
   geokit (~> 1.8.4)
358 410
   geokit-rails (~> 2.0.1)
411
+  google-api-client
359 412
   hipchat (~> 1.2.0)
360 413
   jquery-rails (~> 3.1.0)
361 414
   json (~> 1.8.1)
362
-  jsonpath (~> 0.5.3)
363
-  kaminari (~> 0.15.1)
415
+  jsonpath (~> 0.5.6)
416
+  kaminari (~> 0.16.1)
364 417
   kramdown (~> 1.3.3)
365 418
   liquid (~> 2.6.1)
366 419
   mqtt
367
-  mysql2 (~> 0.3.15)
420
+  mysql2 (~> 0.3.16)
368 421
   nokogiri (~> 1.6.1)
369 422
   omniauth
370 423
   omniauth-37signals
371 424
   omniauth-github
372 425
   omniauth-twitter
373
-  protected_attributes (~> 1.0.7)
426
+  pg
427
+  protected_attributes (~> 1.0.8)
374 428
   pry
429
+  quiet_assets
375 430
   rack
376
-  rails (= 4.1.1)
431
+  rails (= 4.1.4)
432
+  rails_12factor
377 433
   rr
378
-  rspec
379
-  rspec-rails
434
+  rspec (~> 2.14)
435
+  rspec-rails (~> 2.14)
380 436
   rturk (~> 2.12.1)
381 437
   ruby-growl (~> 4.1.0)
382
-  rufus-scheduler (~> 3.0.7)
438
+  rufus-scheduler (~> 3.0.8)
383 439
   sass-rails (~> 4.0.0)
384 440
   select2-rails (~> 3.5.4)
385 441
   shoulda-matchers
@@ -387,11 +443,12 @@ DEPENDENCIES
387 443
   therubyracer (~> 0.12.1)
388 444
   twilio-ruby (~> 3.11.5)
389 445
   twitter (~> 5.8.0)
390
-  twitter-stream!
391 446
   typhoeus (~> 0.6.3)
392 447
   tzinfo-data
393 448
   uglifier (>= 1.3.0)
394
-  webmock
449
+  unicorn
450
+  vcr
451
+  webmock (~> 1.17.4)
395 452
   weibo_2 (~> 0.1.4)
396 453
   wunderground (~> 1.2.0)
397 454
   xmpp4r (~> 0.5.6)

+ 5 - 5
Procfile

@@ -6,8 +6,8 @@ jobs: bundle exec rails runner bin/threaded.rb
6 6
 # web: bundle exec unicorn -c config/unicorn/production.rb
7 7
 # jobs: bundle exec rails runner bin/threaded.rb
8 8
 
9
-# Old version with seperate processes (use this if you have issues with the threaded version)
10
-#web: bundle exec rails server
11
-#schedule: bundle exec rails runner bin/schedule.rb
12
-#twitter: bundle exec rails runner bin/twitter_stream.rb
13
-#dj: bundle exec script/delayed_job run
9
+# Old version with separate processes (use this if you have issues with the threaded version)
10
+# web: bundle exec rails server
11
+# schedule: bundle exec rails runner bin/schedule.rb
12
+# twitter: bundle exec rails runner bin/twitter_stream.rb
13
+# dj: bundle exec script/delayed_job run

+ 9 - 10
README.md

@@ -2,7 +2,7 @@
2 2
 
3 3
 ## What is Huginn?
4 4
 
5
-Huginn is a system for building agents that perform automated tasks for you online.  They can read the web, watch for events, and take actions on your behalf.  Huginn's Agents create and consume events, propagating them along a directed event flow graph.  Think of it as Yahoo! Pipes plus IFTTT on your own server.  You always know who has your data.  You do.
5
+Huginn is a system for building agents that perform automated tasks for you online.  They can read the web, watch for events, and take actions on your behalf.  Huginn's Agents create and consume events, propagating them along a directed graph.  Think of it as a hackable Yahoo! Pipes plus IFTTT on your own server.  You always know who has your data.  You do.
6 6
 
7 7
 ![the origin of the name](doc/imgs/the-name.png)
8 8
 
@@ -10,12 +10,13 @@ Huginn is a system for building agents that perform automated tasks for you onli
10 10
 
11 11
 * Track the weather and get an email when it's going to rain (or snow) tomorrow ("Don't forget your umbrella!")
12 12
 * List terms that you care about and receive emails when their occurrence on Twitter changes.  (For example, want to know when something interesting has happened in the world of Machine Learning?  Huginn will watch the term "machine learning" on Twitter and tell you when there is a large spike.)
13
-* Watch for air travel deals
13
+* Watch for air travel or shopping deals
14 14
 * Follow your project names on Twitter and get updates when people mention them
15 15
 * Scrape websites and receive emails when they change
16
+* Connect to Adioso, HipChat, Basecamp, Growl, FTP, IMAP, Jabber, JIRA, MQTT, nextbus, Pushbullet, Pushover, RSS, Bash, Slack, StubHub, translation APIs, Twilio, Twitter, Wunderground, and Weibo, to name a few.
16 17
 * Compose digest emails about things you care about to be sent at specific times of the day
17 18
 * Track counts of high frequency events and send an SMS within moments when they spike, such as the term "san francisco emergency"
18
-* Watch public transit
19
+* Send and receive WebHooks
19 20
 * Run arbitrary JavaScript Agents on the server
20 21
 * Track your location over time
21 22
 * Create Amazon Mechanical Turk workflows as the inputs, or outputs, of agents.  ("Once a day, ask 5 people for a funny cat photo; send the results to 5 more people to be rated; send the top-rated photo to 5 people for a funny caption; send to 5 final people to rate for funniest caption; finally, post the best captioned photo on my blog.")
@@ -26,6 +27,8 @@ Follow [@tectonic](https://twitter.com/tectonic) for updates as Huginn evolves,
26 27
 
27 28
 Want to help with Huginn?  All contributions are encouraged!  You could make UI improvements, add new Agents, write documentation and tutorials, or try tackling [issues tagged with #help-wanted](https://github.com/cantino/huginn/issues?direction=desc&labels=help-wanted&page=1&sort=created&state=open).
28 29
 
30
+Have an awesome an idea but not feeling quite up to contributing yet? Head over to our [Official 'suggest an agent' thread ](https://github.com/cantino/huginn/issues/353) and tell us about your cool idea!
31
+
29 32
 ## Examples
30 33
 
31 34
 Please checkout the [Huginn Introductory Screencast](http://vimeo.com/61976251)!
@@ -66,7 +69,7 @@ If you need more detailed instructions, see the [Novice setup guide][novice-setu
66 69
 
67 70
 ## Deployment
68 71
 
69
-Please see [the Huginn Wiki](https://github.com/cantino/huginn/wiki#deploying-huginn) for detailed deployment strategies for different providers.
72
+Huginn can run on Heroku for free!  Please see [the Huginn Wiki](https://github.com/cantino/huginn/wiki#deploying-huginn) for detailed deployment strategies for different providers.
70 73
 
71 74
 ### Optional Setup
72 75
 
@@ -76,11 +79,7 @@ See [private development instructions](https://github.com/cantino/huginn/wiki/Pr
76 79
 
77 80
 #### Enable the WeatherAgent
78 81
 
79
-In order to use the WeatherAgent you need an [API key with Wunderground](http://www.wunderground.com/weather/api/). Signup for one and then change value of `api_key: your-key` in your seeded WeatherAgent.
80
-
81
-#### Logging your location to the UserLocationAgent
82
-
83
-You can use [Post Location](https://github.com/cantino/post_location) on your iPhone to post your location to an instance of the UserLocationAgent.  Make a new one to see instructions.
82
+In order to use the WeatherAgent you need an [API key with Wunderground](http://www.wunderground.com/weather/api/). Signup for one and then change the value of `api_key: your-key` in your seeded WeatherAgent.
84 83
 
85 84
 #### Enable DelayedJobWeb for handy delayed\_job monitoring and control
86 85
 
@@ -102,7 +101,7 @@ Some of us are hanging out there, come and say hello.
102 101
 
103 102
 ## Contribution
104 103
 
105
-Huginn is a work in progress and is hopefully just getting started.  Please get involved!  You can [add new Agents](https://github.com/cantino/huginn/wiki/Creating-a-new-agent), expand the [Wiki](https://github.com/cantino/huginn/wiki), or help us simplify and strengthen the Agent API or core application.
104
+Huginn is a work in progress and is just getting started.  Please get involved!  You can [add new Agents](https://github.com/cantino/huginn/wiki/Creating-a-new-agent), expand the [Wiki](https://github.com/cantino/huginn/wiki), or help us simplify and strengthen the Agent API or core application.
106 105
 
107 106
 Please fork, add specs, and send pull requests!
108 107
 

+ 6 - 3
app/assets/javascripts/application.js.coffee.erb

@@ -25,7 +25,10 @@ hideSchedule = ->
25 25
   $(".schedule-region select").hide()
26 26
   $(".schedule-region .cannot-be-scheduled").show()
27 27
 
28
-showSchedule = ->
28
+showSchedule = (defaultSchedule = null) ->
29
+  $(".schedule-region select").show()
30
+  if defaultSchedule?
31
+    $(".schedule-region select").val(defaultSchedule).change()
29 32
   $(".schedule-region select").show()
30 33
   $(".schedule-region .cannot-be-scheduled").hide()
31 34
 
@@ -65,7 +68,7 @@ $(document).ready ->
65 68
     setTimeout((-> $(".flash").slideUp(-> $(".flash").remove())), 5000)
66 69
 
67 70
   # Help popovers
68
-  $('.hover-help').popover(trigger: 'hover')
71
+  $('.hover-help').popover(trigger: 'hover', html: true)
69 72
 
70 73
   # Agent Navigation
71 74
   $agentNavigate = $('#agent-navigate')
@@ -145,7 +148,7 @@ $(document).ready ->
145 148
       $(".event-descriptions").html("").hide()
146 149
       $.getJSON "/agents/type_details", { type: $(@).val() }, (json) =>
147 150
         if json.can_be_scheduled
148
-          showSchedule()
151
+          showSchedule(json.default_schedule)
149 152
         else
150 153
           hideSchedule()
151 154
 

+ 6 - 0
app/assets/stylesheets/application.css.scss.erb

@@ -156,6 +156,12 @@ span.not-applicable:after {
156 156
   top: 2px;
157 157
 }
158 158
 
159
+.popover {
160
+  dd {
161
+    margin-left: 1em;
162
+  }
163
+}
164
+
159 165
 h2 .scenario, a span.label.scenario {
160 166
   position: relative;
161 167
   top: -2px;

+ 21 - 0
app/concerns/email_concern.rb

@@ -9,6 +9,27 @@ module EmailConcern
9 9
 
10 10
   def validate_email_options
11 11
     errors.add(:base, "subject and expected_receive_period_in_days are required") unless options['subject'].present? && options['expected_receive_period_in_days'].present?
12
+
13
+    if options['recipients'].present?
14
+      emails = options['recipients']
15
+      emails = [emails] if emails.is_a?(String)
16
+      unless emails.all? { |email| email =~ Devise.email_regexp }
17
+        errors.add(:base, "'when provided, 'recipients' should be an email address or an array of email addresses")
18
+      end
19
+    end
20
+  end
21
+
22
+  def recipients(payload = {})
23
+    emails = interpolated(payload)['recipients']
24
+    if emails.present?
25
+      if emails.is_a?(String)
26
+        [emails]
27
+      else
28
+        emails
29
+      end
30
+    else
31
+      [user.email]
32
+    end
12 33
   end
13 34
 
14 35
   def working?

+ 17 - 0
app/concerns/liquid_droppable.rb

@@ -0,0 +1,17 @@
1
+module LiquidDroppable
2
+  extend ActiveSupport::Concern
3
+
4
+  class Drop < Liquid::Drop
5
+    def initialize(object)
6
+      @object = object
7
+    end
8
+  end
9
+
10
+  included do
11
+    const_set :Drop, Kernel.const_set("#{name}Drop", Class.new(Drop))
12
+  end
13
+
14
+  def to_liquid(*args)
15
+    self.class::Drop.new(self, *args)
16
+  end
17
+end

+ 9 - 9
app/concerns/liquid_interpolatable.rb

@@ -1,28 +1,28 @@
1 1
 module LiquidInterpolatable
2 2
   extend ActiveSupport::Concern
3 3
 
4
-  def interpolate_options(options, payload = {})
4
+  def interpolate_options(options, event = {})
5 5
     case options
6 6
       when String
7
-        interpolate_string(options, payload)
7
+        interpolate_string(options, event)
8 8
       when ActiveSupport::HashWithIndifferentAccess, Hash
9
-        options.inject(ActiveSupport::HashWithIndifferentAccess.new) { |memo, (key, value)| memo[key] = interpolate_options(value, payload); memo }
9
+        options.inject(ActiveSupport::HashWithIndifferentAccess.new) { |memo, (key, value)| memo[key] = interpolate_options(value, event); memo }
10 10
       when Array
11
-        options.map { |value| interpolate_options(value, payload) }
11
+        options.map { |value| interpolate_options(value, event) }
12 12
       else
13 13
         options
14 14
     end
15 15
   end
16 16
 
17
-  def interpolated(payload = {})
18
-    key = [options, payload]
17
+  def interpolated(event = {})
18
+    key = [options, event]
19 19
     @interpolated_cache ||= {}
20
-    @interpolated_cache[key] ||= interpolate_options(options, payload)
20
+    @interpolated_cache[key] ||= interpolate_options(options, event)
21 21
     @interpolated_cache[key]
22 22
   end
23 23
 
24
-  def interpolate_string(string, payload)
25
-    Liquid::Template.parse(string).render!(payload, registers: {agent: self})
24
+  def interpolate_string(string, event)
25
+    Liquid::Template.parse(string).render!(event.to_liquid, registers: {agent: self})
26 26
   end
27 27
 
28 28
   require 'uri'

+ 61 - 0
app/concerns/web_request_concern.rb

@@ -0,0 +1,61 @@
1
+module WebRequestConcern
2
+  extend ActiveSupport::Concern
3
+
4
+  def validate_web_request_options!
5
+    if options['user_agent'].present?
6
+      errors.add(:base, "user_agent must be a string") unless options['user_agent'].is_a?(String)
7
+    end
8
+
9
+    unless headers(options['headers']).is_a?(Hash)
10
+      errors.add(:base, "if provided, headers must be a hash")
11
+    end
12
+
13
+    begin
14
+      basic_auth_credentials(options['basic_auth'])
15
+    rescue ArgumentError => e
16
+      errors.add(:base, e.message)
17
+    end
18
+  end
19
+
20
+  def faraday
21
+    @faraday ||= Faraday.new { |builder|
22
+      builder.headers = headers if headers.length > 0
23
+
24
+      if (user_agent = interpolated['user_agent']).present?
25
+        builder.headers[:user_agent] = user_agent
26
+      end
27
+
28
+      builder.use FaradayMiddleware::FollowRedirects
29
+      builder.request :url_encoded
30
+      if userinfo = basic_auth_credentials
31
+        builder.request :basic_auth, *userinfo
32
+      end
33
+
34
+      case backend = faraday_backend
35
+        when :typhoeus
36
+          require 'typhoeus/adapters/faraday'
37
+      end
38
+      builder.adapter backend
39
+    }
40
+  end
41
+
42
+  def headers(value = interpolated['headers'])
43
+    value.presence || {}
44
+  end
45
+
46
+  def basic_auth_credentials(value = interpolated['basic_auth'])
47
+    case value
48
+      when nil, ''
49
+        return nil
50
+      when Array
51
+        return value if value.size == 2
52
+      when /:/
53
+        return value.split(/:/, 2)
54
+    end
55
+    raise ArgumentError.new("bad value for basic_auth: #{value.inspect}")
56
+  end
57
+
58
+  def faraday_backend
59
+    ENV.fetch('FARADAY_HTTP_BACKEND', 'typhoeus').to_sym
60
+  end
61
+end

+ 7 - 7
app/controllers/agents_controller.rb

@@ -31,14 +31,14 @@ class AgentsController < ApplicationController
31 31
   end
32 32
 
33 33
   def type_details
34
-    @agent = Agent.build_for_type(params[:type], current_user, {})
34
+    agent = Agent.build_for_type(params[:type], current_user, {})
35 35
     render :json => {
36
-        :can_be_scheduled => @agent.can_be_scheduled?,
37
-        :can_receive_events => @agent.can_receive_events?,
38
-        :can_create_events => @agent.can_create_events?,
39
-        :options => @agent.default_options,
40
-        :description_html => @agent.html_description,
41
-        :form => render_to_string(partial: 'form')
36
+        :can_be_scheduled => agent.can_be_scheduled?,
37
+        :default_schedule => agent.default_schedule,
38
+        :can_receive_events => agent.can_receive_events?,
39
+        :can_create_events => agent.can_create_events?,
40
+        :options => agent.default_options,
41
+        :description_html => agent.html_description
42 42
     }
43 43
   end
44 44
 

+ 8 - 0
app/helpers/application_helper.rb

@@ -7,6 +7,14 @@ module ApplicationHelper
7 7
     HTML
8 8
   end
9 9
 
10
+  def yes_no(bool)
11
+    if bool
12
+      '<span class="label label-info">Yes</span>'.html_safe
13
+    else
14
+      '<span class="label label-default">No</span>'.html_safe
15
+    end
16
+  end
17
+
10 18
   def working(agent)
11 19
     if agent.disabled?
12 20
       link_to 'Disabled', agent_path(agent), :class => 'label label-warning'

+ 137 - 19
app/helpers/dot_helper.rb

@@ -14,32 +14,150 @@ module DotHelper
14 14
     end
15 15
   end
16 16
 
17
-  private
17
+  class DotDrawer
18
+    def initialize(vars = {})
19
+      @dot = ''
20
+      vars.each { |name, value|
21
+        # Import variables as methods
22
+        define_singleton_method(name) { value }
23
+      }
24
+    end
25
+
26
+    def to_s
27
+      @dot
28
+    end
29
+
30
+    def self.draw(*args, &block)
31
+      drawer = new(*args)
32
+      drawer.instance_exec(&block)
33
+      drawer.to_s
34
+    end
35
+
36
+    def raw(string)
37
+      @dot << string
38
+    end
39
+
40
+    def escape(string)
41
+      # Backslash escaping seems to work for the backslash itself,
42
+      # though it's not documented in the DOT language docs.
43
+      string.gsub(/[\\"\n]/,
44
+                  "\\" => "\\\\",
45
+                  "\"" => "\\\"",
46
+                  "\n" => "\\n")
47
+    end
48
+
49
+    def id(value)
50
+      case string = value.to_s
51
+      when /\A(?!\d)\w+\z/, /\A(?:\.\d+|\d+(?:\.\d*)?)\z/
52
+        raw string
53
+      else
54
+        raw '"'
55
+        raw escape(string)
56
+        raw '"'
57
+      end
58
+    end
59
+
60
+    def attr_list(attrs = nil)
61
+      return if attrs.nil?
62
+      attrs = attrs.select { |key, value| value.present? }
63
+      return if attrs.empty?
64
+      raw '['
65
+      attrs.each_with_index { |(key, value), i|
66
+        raw ',' if i > 0
67
+        id key
68
+        raw '='
69
+        id value
70
+      }
71
+      raw ']'
72
+    end
18 73
 
19
-  def dot_id(string)
20
-    # Backslash escaping seems to work for the backslash itself,
21
-    # despite the DOT language document.
22
-    '"%s"' % string.gsub(/\\/, "\\\\\\\\").gsub(/"/, "\\\\\"")
74
+    def node(id, attrs = nil)
75
+      id id
76
+      attr_list attrs
77
+      raw ';'
78
+    end
79
+
80
+    def edge(from, to, attrs = nil, op = '->')
81
+      id from
82
+      raw op
83
+      id to
84
+      attr_list attrs
85
+      raw ';'
86
+    end
87
+
88
+    def statement(ids, attrs = nil)
89
+      Array(ids).each_with_index { |id, i|
90
+        raw ' ' if i > 0
91
+        id id
92
+      }
93
+      attr_list attrs
94
+      raw ';'
95
+    end
96
+
97
+    def block(title, &block)
98
+      raw title
99
+      raw '{'
100
+      block.call
101
+      raw '}'
102
+    end
23 103
   end
24 104
 
25
-  def disabled_label(agent)
26
-    agent.disabled? ? dot_id(agent.name + " (Disabled)") : dot_id(agent.name)
105
+  private
106
+
107
+  def draw(vars = {}, &block)
108
+    DotDrawer.draw(vars, &block)
27 109
   end
28 110
 
29 111
   def agents_dot(agents, rich = false)
30
-    "digraph foo {".tap { |dot|
31
-      agents.each.with_index do |agent, index|
32
-        if rich
33
-          dot << '%s[URL=%s];' % [disabled_label(agent), dot_id(agent_path(agent.id))]
34
-        else
35
-          dot << '%s;' % disabled_label(agent)
36
-        end
37
-        agent.receivers.each do |receiver|
38
-          next unless agents.include?(receiver)
39
-          dot << "%s->%s;" % [disabled_label(agent), disabled_label(receiver)]
40
-        end
112
+    draw(agents: agents,
113
+         agent_id: ->agent { 'a%d' % agent.id },
114
+         agent_label: ->agent {
115
+           if agent.disabled?
116
+             '%s (Disabled)' % agent.name
117
+           else
118
+             agent.name
119
+           end.gsub(/(.{20}\S*)\s+/) {
120
+             # Fold after every 20+ characters
121
+             $1 + "\n"
122
+           }
123
+         },
124
+         agent_url: ->agent { agent_path(agent.id) },
125
+         rich: rich) {
126
+      @disabled = '#999999'
127
+
128
+      def agent_node(agent)
129
+        node(agent_id[agent],
130
+             label: agent_label[agent],
131
+             URL: (agent_url[agent] if rich),
132
+             style: ('rounded,dashed' if agent.disabled?),
133
+             color: (@disabled if agent.disabled?),
134
+             fontcolor: (@disabled if agent.disabled?))
135
+      end
136
+
137
+      def agent_edge(agent, receiver)
138
+        edge(agent_id[agent],
139
+             agent_id[receiver],
140
+             style: ('dashed' unless receiver.propagate_immediately),
141
+             color: (@disabled if agent.disabled? || receiver.disabled?))
41 142
       end
42
-      dot << "}"
143
+
144
+      block('digraph foo') {
145
+        # statement 'graph', rankdir: 'LR'
146
+        statement 'node',
147
+                  shape: 'box',
148
+                  style: 'rounded',
149
+                  target: '_blank',
150
+                  fontsize: 10,
151
+                  fontname: ('Helvetica' if rich)
152
+
153
+        agents.each.with_index { |agent, index|
154
+          agent_node(agent)
155
+
156
+          agent.receivers.each { |receiver|
157
+            agent_edge(agent, receiver) if agents.include?(receiver)
158
+          }
159
+        }
160
+      }
43 161
     }
44 162
   end
45 163
 end

+ 51 - 0
app/models/agent.rb

@@ -14,6 +14,7 @@ class Agent < ActiveRecord::Base
14 14
   include WorkingHelpers
15 15
   include LiquidInterpolatable
16 16
   include HasGuid
17
+  include LiquidDroppable
17 18
 
18 19
   markdown_class_attributes :description, :event_description
19 20
 
@@ -67,6 +68,10 @@ class Agent < ActiveRecord::Base
67 68
     where(:type => type)
68 69
   }
69 70
 
71
+  def short_type
72
+    type.demodulize
73
+  end
74
+
70 75
   def check
71 76
     # Implement me in your subclass of Agent.
72 77
   end
@@ -226,6 +231,19 @@ class Agent < ActiveRecord::Base
226 231
     # Implement me in your subclass to test for valid options.
227 232
   end
228 233
 
234
+  # Utility Methods
235
+
236
+  def boolify(option_value)
237
+    case option_value
238
+    when true, 'true'
239
+      true
240
+    when false, 'false'
241
+      false
242
+    else
243
+      nil
244
+    end
245
+  end
246
+
229 247
   # Class Methods
230 248
 
231 249
   class << self
@@ -366,3 +384,36 @@ class Agent < ActiveRecord::Base
366 384
     handle_asynchronously :async_check
367 385
   end
368 386
 end
387
+
388
+class AgentDrop
389
+  def type
390
+    @object.short_type
391
+  end
392
+
393
+  METHODS = [
394
+    :name,
395
+    :type,
396
+    :options,
397
+    :memory,
398
+    :sources,
399
+    :receivers,
400
+    :schedule,
401
+    :disabled,
402
+    :keep_events_for,
403
+    :propagate_immediately,
404
+  ]
405
+
406
+  METHODS.each { |attr|
407
+    define_method(attr) {
408
+      @object.__send__(attr)
409
+    } unless method_defined?(attr)
410
+  }
411
+
412
+  def each(&block)
413
+    return to_enum(__method__) unless block
414
+
415
+    METHODS.each { |attr|
416
+      yield [attr, __sent__(attr)]
417
+    }
418
+  end
419
+end

+ 1 - 1
app/models/agents/data_output_agent.rb

@@ -83,7 +83,7 @@ module Agents
83 83
     def receive_web_request(params, method, format)
84 84
       if interpolated['secrets'].include?(params['secret'])
85 85
         items = received_events.order('id desc').limit(events_to_show).map do |event|
86
-          interpolated = interpolate_options(options['template']['item'], event.payload)
86
+          interpolated = interpolate_options(options['template']['item'], event)
87 87
           interpolated['guid'] = event.id
88 88
           interpolated['pubDate'] = event.created_at.rfc2822.to_s
89 89
           interpolated

+ 9 - 4
app/models/agents/email_agent.rb

@@ -7,9 +7,12 @@ module Agents
7 7
 
8 8
     description <<-MD
9 9
       The EmailAgent sends any events it receives via email immediately.
10
-      The email will be sent to your account's address and will have a `subject` and an optional `headline` before
11
-      listing the Events.  If the Events' payloads contain a `:message`, that will be highlighted, otherwise everything in
12
-      their payloads will be shown.
10
+
11
+      The email will have a `subject` and an optional `headline` before listing the Events.  If the Events' payloads
12
+      contain a `:message`, that will be highlighted, otherwise everything in their payloads will be shown.
13
+
14
+      You can specify one or more `recipients` for the email, or skip the option in order to send the email to your
15
+      account's default email address.
13 16
 
14 17
       Set `expected_receive_period_in_days` to the maximum amount of time that you'd expect to pass between Events being received by this Agent.
15 18
     MD
@@ -25,7 +28,9 @@ module Agents
25 28
     def receive(incoming_events)
26 29
       incoming_events.each do |event|
27 30
         log "Sending digest mail to #{user.email} with event #{event.id}"
28
-        SystemMailer.delay.send_message(:to => user.email, :subject => interpolated(event.payload)['subject'], :headline => interpolated(event.payload)['headline'], :groups => [present(event.payload)])
31
+        recipients(event.payload).each do |recipient|
32
+          SystemMailer.delay.send_message(:to => recipient, :subject => interpolated(event)['subject'], :headline => interpolated(event)['headline'], :groups => [present(event.payload)])
33
+        end
29 34
       end
30 35
     end
31 36
   end

+ 10 - 4
app/models/agents/email_digest_agent.rb

@@ -7,11 +7,15 @@ module Agents
7 7
     cannot_create_events!
8 8
 
9 9
     description <<-MD
10
-      The EmailDigestAgent collects any Events sent to it and sends them all via email when run.
11
-      The email will be sent to your account's address and will have a `subject` and an optional `headline` before
12
-      listing the Events.  If the Events' payloads contain a `message`, that will be highlighted, otherwise everything in
10
+      The EmailDigestAgent collects any Events sent to it and sends them all via email when scheduled.
11
+
12
+      By default, the will have a `subject` and an optional `headline` before listing the Events.  If the Events'
13
+      payloads contain a `message`, that will be highlighted, otherwise everything in
13 14
       their payloads will be shown.
14 15
 
16
+      You can specify one or more `recipients` for the email, or skip the option in order to send the email to your
17
+      account's default email address.
18
+
15 19
       Set `expected_receive_period_in_days` to the maximum amount of time that you'd expect to pass between Events being received by this Agent.
16 20
     MD
17 21
 
@@ -37,7 +41,9 @@ module Agents
37 41
         ids = self.memory['events'].join(",")
38 42
         groups = self.memory['queue'].map { |payload| present(payload) }
39 43
         log "Sending digest mail to #{user.email} with events [#{ids}]"
40
-        SystemMailer.delay.send_message(:to => user.email, :subject => interpolated['subject'], :headline => interpolated['headline'], :groups => groups)
44
+        recipients.each do |recipient|
45
+          SystemMailer.delay.send_message(:to => recipient, :subject => interpolated['subject'], :headline => interpolated['headline'], :groups => groups)
46
+        end
41 47
         self.memory['queue'] = []
42 48
         self.memory['events'] = []
43 49
       end

+ 7 - 6
app/models/agents/event_formatting_agent.rb

@@ -28,6 +28,8 @@ module Agents
28 28
             "subject": "{{data}}"
29 29
           }
30 30
 
31
+      The upstream agent of each received event is accessible via the key `agent`, which has the following attributes: #{''.tap { |s| s << AgentDrop.instance_methods(false).map { |m| "`#{m}`" }.join(', ') }}.
32
+
31 33
       Have a look at the [Wiki](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) to learn more about liquid templating.
32 34
 
33 35
       Events generated by this possible Event Formatting Agent will look like:
@@ -60,13 +62,13 @@ module Agents
60 62
       So you can use it in `instructions` like this:
61 63
 
62 64
           "instructions": {
63
-            "message": "Today's conditions look like <$.conditions> with a high temperature of {{high.celsius}} degrees Celsius according to the forecast at {{pretty_date.time}}.",
65
+            "message": "Today's conditions look like {{conditions}} with a high temperature of {{high.celsius}} degrees Celsius according to the forecast at {{pretty_date.time}}.",
64 66
             "subject": "{{data}}"
65 67
           }
66 68
 
67 69
       If you want to retain original contents of events and only add new keys, then set `mode` to `merge`, otherwise set it to `clean`.
68 70
 
69
-      By default, the output event will have `agent` and `created_at` fields added as well, reflecting the original Agent type and Event creation time.  You can skip these outputs by setting `skip_agent` and `skip_created_at` to `true`.
71
+      By default, the output event will have a `created_at` field added as well, reflecting the original Event creation time.  You can skip this output by setting `skip_created_at` to `true`.
70 72
 
71 73
       To CGI escape output (for example when creating a link), use the Liquid `uri_escape` filter, like so:
72 74
 
@@ -80,7 +82,7 @@ module Agents
80 82
     after_save :clear_matchers
81 83
 
82 84
     def validate_options
83
-      errors.add(:base, "instructions, mode, skip_agent, and skip_created_at all need to be present.") unless options['instructions'].present? && options['mode'].present? && options['skip_agent'].present? && options['skip_created_at'].present?
85
+      errors.add(:base, "instructions, mode, and skip_created_at all need to be present.") unless options['instructions'].present? && options['mode'].present? && options['skip_created_at'].present?
84 86
 
85 87
       validate_matchers
86 88
     end
@@ -89,11 +91,11 @@ module Agents
89 91
       {
90 92
         'instructions' => {
91 93
           'message' =>  "You received a text {{text}} from {{fields.from}}",
94
+          'agent' => "{{agent.type}}",
92 95
           'some_other_field' => "Looks like the weather is going to be {{fields.weather}}"
93 96
         },
94 97
         'matchers' => [],
95 98
         'mode' => "clean",
96
-        'skip_agent' => "false",
97 99
         'skip_created_at' => "false"
98 100
       }
99 101
     end
@@ -105,10 +107,9 @@ module Agents
105 107
     def receive(incoming_events)
106 108
       incoming_events.each do |event|
107 109
         payload = perform_matching(event.payload)
108
-        opts = interpolated(payload)
110
+        opts = interpolated(event.to_liquid(payload))
109 111
         formatted_event = opts['mode'].to_s == "merge" ? event.payload.dup : {}
110 112
         formatted_event.merge! opts['instructions']
111
-        formatted_event['agent'] = Agent.find(event.agent_id).type.slice!(8..-1) unless opts['skip_agent'].to_s == "true"
112 113
         formatted_event['created_at'] = event.created_at unless opts['skip_created_at'].to_s == "true"
113 114
         create_event :payload => formatted_event
114 115
       end

+ 105 - 0
app/models/agents/google_calendar_publish_agent.rb

@@ -0,0 +1,105 @@
1
+require 'json'
2
+
3
+module Agents
4
+  class GoogleCalendarPublishAgent < Agent
5
+    cannot_be_scheduled!
6
+
7
+    description <<-MD
8
+      The GoogleCalendarPublishAgent creates events on your google calendar.
9
+
10
+      This agent relies on service accounts, rather than oauth.
11
+
12
+      Setup:
13
+
14
+      1. Visit [the google api console](https://code.google.com/apis/console/b/0/)
15
+      2. New project -> Huginn
16
+      3. APIs & Auth -> Enable google calendar
17
+      4. Credentials -> Create new Client ID -> Service Account
18
+      5. Persist the generated private key to a path, ie: `/home/hugin/a822ccdefac89fac6330f95039c492dfa3ce6843.p12`
19
+      6. Grant access via google calendar UI to the service account email address for each calendar you wish to manage. For a whole google apps domain, you can [delegate authority](https://developers.google.com/+/domains/authentication/delegation)
20
+
21
+
22
+      Agent Configuration:
23
+
24
+      `calendar_id` - The id the calendar you want to publish to. Typically your google account email address.
25
+
26
+      `google` A hash of configuration options for the agent.
27
+
28
+      `google` `service_account_email` - The authorised service account.
29
+
30
+      `google` `key_file` - The path to the key file.
31
+
32
+      `google` `key_secret` - The secret for the key, typically 'notasecret'
33
+
34
+      
35
+
36
+      Set `expected_update_period_in_days` to the maximum amount of time that you'd expect to pass between Events being created by this Agent.
37
+
38
+      Use it with a trigger agent to shape your payload!
39
+
40
+      A hash of event details. See the [Google Calendar API docs](https://developers.google.com/google-apps/calendar/v3/reference/events/insert)
41
+
42
+      Example payload for trigger agent:
43
+      <pre><code>{
44
+        "message": {
45
+          "visibility": "default",
46
+          "summary": "Awesome event",
47
+          "description": "An example event with text. Pro tip: DateTimes are in RFC3339",
48
+          "start": {
49
+            "dateTime": "2014-10-02T10:00:00-05:00"
50
+          },
51
+          "end": {
52
+            "dateTime": "2014-10-02T11:00:00-05:00"
53
+          }
54
+        }
55
+      }</code></pre>
56
+    MD
57
+
58
+    event_description <<-MD
59
+      {
60
+        'success' => true,
61
+        'published_calendar_event' => {
62
+           ....
63
+        },
64
+        'agent_id' => 1234,
65
+        'event_id' => 3432,
66
+      }
67
+    MD
68
+
69
+    def validate_options
70
+      errors.add(:base, "expected_update_period_in_days is required") unless options['expected_update_period_in_days'].present?
71
+    end
72
+
73
+    def working?
74
+      event_created_within?(options['expected_update_period_in_days']) && most_recent_event && most_recent_event.payload['success'] == true && !recent_error_logs?
75
+    end
76
+
77
+    def default_options
78
+      {
79
+        'expected_update_period_in_days' => "10",
80
+        'calendar_id' => 'you@email.com',
81
+        'google' => {
82
+          'key_file' => '/path/to/private.key',
83
+          'key_secret' => 'notasecret',
84
+          'service_account_email' => ''
85
+        }
86
+      }
87
+    end
88
+
89
+    def receive(incoming_events)
90
+     incoming_events.each do |event|
91
+        calendar = GoogleCalendar.new(options, Rails.logger)
92
+
93
+        calendar_event = JSON.parse(calendar.publish_as(options['calendar_id'], event.payload["message"]).response.body)
94
+  
95
+        create_event :payload => {
96
+          'success' => true,
97
+          'published_calendar_event' => calendar_event,
98
+          'agent_id' => event.agent_id,
99
+          'event_id' => event.id
100
+        }
101
+      end
102
+    end
103
+  end
104
+end
105
+

+ 2 - 2
app/models/agents/growl_agent.rb

@@ -51,7 +51,7 @@ module Agents
51 51
         message = (event.payload['message'] || event.payload['text']).to_s
52 52
         subject = event.payload['subject'].to_s
53 53
         if message.present? && subject.present?
54
-          log "Sending Growl notification '#{subject}': '#{message}' to #{interpolated(event.payload)['growl_server']} with event #{event.id}"
54
+          log "Sending Growl notification '#{subject}': '#{message}' to #{interpolated(event)['growl_server']} with event #{event.id}"
55 55
           notify_growl(subject,message)
56 56
         else
57 57
           log "Event #{event.id} not sent, message and subject expected"
@@ -59,4 +59,4 @@ module Agents
59 59
       end
60 60
     end
61 61
   end
62
-end
62
+end

+ 1 - 1
app/models/agents/hipchat_agent.rb

@@ -42,7 +42,7 @@ module Agents
42 42
     def receive(incoming_events)
43 43
       client = HipChat::Client.new(interpolated[:auth_token])
44 44
       incoming_events.each do |event|
45
-        mo = interpolated(event.payload)
45
+        mo = interpolated(event)
46 46
         client[mo[:room_name]].send(mo[:username], mo[:message], :notify => mo[:notify].to_s == 'true' ? 1 : 0, :color => mo[:color])
47 47
       end
48 48
     end

+ 149 - 47
app/models/agents/imap_folder_agent.rb

@@ -11,7 +11,9 @@ module Agents
11 11
     description <<-MD
12 12
 
13 13
       The ImapFolderAgent checks an IMAP server in specified folders
14
-      and creates Events based on new unread mails.
14
+      and creates Events based on new mails found since the last run.
15
+      In the first visit to a foler, this agent only checks for the
16
+      initial status and does not create events.
15 17
 
16 18
       Specify an IMAP server to connect with `host`, and set `ssl` to
17 19
       true if the server supports IMAP over SSL.  Specify `port` if
@@ -65,6 +67,13 @@ module Agents
65 67
           body.  The default value is `['text/plain', 'text/enriched',
66 68
           'text/html']`.
67 69
 
70
+      - "is_unread"
71
+
72
+          Setting this to true or false means only mails that is
73
+          marked as unread or read respectively, are selected.
74
+
75
+          If this key is unspecified or set to null, it is ignored.
76
+
68 77
       - "has_attachment"
69 78
 
70 79
           Setting this to true or false means only mails that does or does
@@ -74,13 +83,16 @@ module Agents
74 83
 
75 84
       Set `mark_as_read` to true to mark found mails as read.
76 85
 
77
-      Each agent instance memorizes a list of unread mails that are
78
-      found in the last run, so even if you change a set of conditions
79
-      so that it matches mails that are missed previously, they will
80
-      not show up as new events.  Also, in order to avoid duplicated
81
-      notification it keeps a list of Message-Id's of 100 most recent
82
-      mails, so if multiple mails of the same Message-Id are found,
83
-      you will only see one event out of them.
86
+      Each agent instance memorizes the highest UID of mails that are
87
+      found in the last run for each watched folder, so even if you
88
+      change a set of conditions so that it matches mails that are
89
+      missed previously, or if you alter the flag status of already
90
+      found mails, they will not show up as new events.
91
+
92
+      Also, in order to avoid duplicated notification it keeps a list
93
+      of Message-Id's of 100 most recent mails, so if multiple mails
94
+      of the same Message-Id are found, you will only see one event
95
+      out of them.
84 96
     MD
85 97
 
86 98
     event_description <<-MD
@@ -138,9 +150,7 @@ module Agents
138 150
 
139 151
       %w[ssl mark_as_read].each { |key|
140 152
         if options[key].present?
141
-          case options[key]
142
-          when true, false
143
-          else
153
+          if boolify(options[key]).nil?
144 154
             errors.add(:base, '%s must be a boolean value' % key)
145 155
           end
146 156
         end
@@ -173,7 +183,6 @@ module Agents
173 183
       end
174 184
 
175 185
       case conditions = options['conditions']
176
-      when nil
177 186
       when Hash
178 187
         conditions.each { |key, value|
179 188
           value.present? or next
@@ -202,8 +211,8 @@ module Agents
202 211
                 errors.add(:base, 'conditions.%s contains a non-string object' % key)
203 212
               end
204 213
             }
205
-          when 'has_attachment'
206
-            case value
214
+          when 'is_unread', 'has_attachment'
215
+            case boolify(value)
207 216
             when true, false
208 217
             else
209 218
               errors.add(:base, 'conditions.%s must be a boolean value or null' % key)
@@ -220,22 +229,8 @@ module Agents
220 229
     end
221 230
 
222 231
     def check
223
-      # 'seen' keeps a hash of { uidvalidity => uids, ... } which
224
-      # lists unread mails in watched folders.
225
-      seen = memory['seen'] || {}
226
-      new_seen = Hash.new { |hash, key|
227
-        hash[key] = []
228
-      }
229
-
230
-      # 'notified' keeps an array of message-ids of {IDCACHE_SIZE}
231
-      # most recent notified mails.
232
-      notified = memory['notified'] || []
233
-
234
-      each_unread_mail { |mail|
235
-        new_seen[mail.uidvalidity] << mail.uid
236
-
237
-        next if (uids = seen[mail.uidvalidity]) && uids.include?(mail.uid)
238
-
232
+      each_unread_mail { |mail, notified|
233
+        message_id = mail.message_id
239 234
         body_parts = mail.body_parts(mime_types)
240 235
         matched_part = nil
241 236
         matches = {}
@@ -274,14 +269,18 @@ module Agents
274 269
               }
275 270
             }
276 271
           when 'has_attachment'
277
-            value == mail.has_attachment?
272
+            boolify(value) == mail.has_attachment?
273
+          when 'is_unread'
274
+            true  # already filtered out by each_unread_mail
278 275
           else
279 276
             log 'Unknown condition key ignored: %s' % key
280 277
             true
281 278
           end
282 279
         } or next
283 280
 
284
-        unless notified.include?(mail.message_id)
281
+        if notified.include?(mail.message_id)
282
+          log 'Ignoring mail: %s (already notified)' % message_id
283
+        else
285 284
           matched_part ||= body_parts.first
286 285
 
287 286
           if matched_part
@@ -292,6 +291,8 @@ module Agents
292 291
             body = ''
293 292
           end
294 293
 
294
+          log 'Emitting an event for mail: %s' % message_id
295
+
295 296
           create_event :payload => {
296 297
             'folder' => mail.folder,
297 298
             'subject' => mail.subject,
@@ -308,43 +309,86 @@ module Agents
308 309
           notified << mail.message_id if mail.message_id
309 310
         end
310 311
 
311
-        if interpolated['mark_as_read']
312
+        if boolify(interpolated['mark_as_read'])
312 313
           log 'Marking as read'
313 314
           mail.mark_as_read
314 315
         end
315 316
       }
316
-
317
-      notified.slice!(0...-IDCACHE_SIZE) if notified.size > IDCACHE_SIZE
318
-
319
-      memory['seen'] = new_seen
320
-      memory['notified'] = notified
321
-      save!
322 317
     end
323 318
 
324 319
     def each_unread_mail
325 320
       host, port, ssl, username = interpolated.values_at(:host, :port, :ssl, :username)
321
+      ssl = boolify(ssl)
322
+      port = (Integer(port) if port.present?)
326 323
 
327 324
       log "Connecting to #{host}#{':%d' % port if port}#{' via SSL' if ssl}"
328
-      Client.open(host, Integer(port), ssl) { |imap|
325
+      Client.open(host, port, ssl) { |imap|
329 326
         log "Logging in as #{username}"
330 327
         imap.login(username, interpolated[:password])
331 328
 
329
+        # 'lastseen' keeps a hash of { uidvalidity => lastseenuid, ... }
330
+        lastseen, seen = self.lastseen, self.make_seen
331
+
332
+        # 'notified' keeps an array of message-ids of {IDCACHE_SIZE}
333
+        # most recent notified mails.
334
+        notified = self.notified
335
+
332 336
         interpolated['folders'].each { |folder|
333 337
           log "Selecting the folder: %s" % folder
334 338
 
335 339
           imap.select(folder)
340
+          uidvalidity = imap.uidvalidity
341
+
342
+          lastseenuid = lastseen[uidvalidity]
336 343
 
337
-          unseen = imap.search('UNSEEN')
344
+          if lastseenuid.nil?
345
+            maxseq = imap.responses['EXISTS'].last
346
+
347
+            log "Recording the initial status: %s" % pluralize(maxseq, 'existing mail')
348
+
349
+            if maxseq > 0
350
+              seen[uidvalidity] = imap.fetch(maxseq, 'UID').last.attr['UID']
351
+            end
338 352
 
339
-          if unseen.empty?
340
-            log "No unread mails"
341 353
             next
342 354
           end
343 355
 
344
-          imap.fetch_mails(unseen).each { |mail|
345
-            yield mail
356
+          seen[uidvalidity] = lastseenuid
357
+          is_unread = boolify(interpolated['conditions']['is_unread'])
358
+
359
+          uids = imap.uid_fetch((lastseenuid + 1)..-1, 'FLAGS').
360
+                 each_with_object([]) { |data, ret|
361
+            uid, flags = data.attr.values_at('UID', 'FLAGS')
362
+            seen[uidvalidity] = uid
363
+            next if uid <= lastseenuid
364
+
365
+            case is_unread
366
+            when nil, !flags.include?(:Seen)
367
+              ret << uid
368
+            end
369
+          }
370
+
371
+          log pluralize(uids.size,
372
+                        case is_unread
373
+                        when true
374
+                          'new unread mail'
375
+                        when false
376
+                          'new read mail'
377
+                        else
378
+                          'new mail'
379
+                        end)
380
+
381
+          next if uids.empty?
382
+
383
+          imap.uid_fetch_mails(uids).each { |mail|
384
+            yield mail, notified
346 385
           }
347 386
         }
387
+
388
+        self.notified = notified
389
+        self.lastseen = seen
390
+
391
+        save!
348 392
       }
349 393
     ensure
350 394
       log 'Connection closed'
@@ -354,6 +398,27 @@ module Agents
354 398
       interpolated['mime_types'] || %w[text/plain text/enriched text/html]
355 399
     end
356 400
 
401
+    def lastseen
402
+      Seen.new(memory['lastseen'])
403
+    end
404
+
405
+    def lastseen= value
406
+      memory.delete('seen')  # obsolete key
407
+      memory['lastseen'] = value
408
+    end
409
+
410
+    def make_seen
411
+      Seen.new
412
+    end
413
+
414
+    def notified
415
+      Notified.new(memory['notified'])
416
+    end
417
+
418
+    def notified= value
419
+      memory['notified'] = value
420
+    end
421
+
357 422
     private
358 423
 
359 424
     def is_positive_integer?(value)
@@ -366,6 +431,10 @@ module Agents
366 431
       File.fnmatch?(pattern, value, FNM_FLAGS)
367 432
     end
368 433
 
434
+    def pluralize(count, noun)
435
+      "%d %s" % [count, noun.pluralize(count)]
436
+    end
437
+
369 438
     class Client < ::Net::IMAP
370 439
       class << self
371 440
         def open(host, port, ssl)
@@ -376,19 +445,52 @@ module Agents
376 445
         end
377 446
       end
378 447
 
448
+      attr_reader :uidvalidity
449
+
379 450
       def select(folder)
380 451
         ret = super(@folder = folder)
381 452
         @uidvalidity = responses['UIDVALIDITY'].last
382 453
         ret
383 454
       end
384 455
 
385
-      def fetch_mails(set)
386
-        fetch(set, %w[UID RFC822.HEADER]).map { |data|
456
+      def uid_fetch_mails(set)
457
+        uid_fetch(set, 'RFC822.HEADER').map { |data|
387 458
           Message.new(self, data, folder: @folder, uidvalidity: @uidvalidity)
388 459
         }
389 460
       end
390 461
     end
391 462
 
463
+    class Seen < Hash
464
+      def initialize(hash = nil)
465
+        super()
466
+        if hash
467
+          # Deserialize a JSON hash which keys are strings
468
+          hash.each { |uidvalidity, uid|
469
+            self[uidvalidity.to_i] = uid
470
+          }
471
+        end
472
+      end
473
+
474
+      def []=(uidvalidity, uid)
475
+        # Update only if the new value is larger than the current value
476
+        if (curr = self[uidvalidity]).nil? || curr <= uid
477
+          super
478
+        end
479
+      end
480
+    end
481
+
482
+    class Notified < Array
483
+      def initialize(array = nil)
484
+        super()
485
+        replace(array) if array
486
+      end
487
+
488
+      def <<(value)
489
+        slice!(0...-IDCACHE_SIZE) if size > IDCACHE_SIZE
490
+        super
491
+      end
492
+    end
493
+
392 494
     class Message < SimpleDelegator
393 495
       DEFAULT_BODY_MIME_TYPES = %w[text/plain text/enriched text/html]
394 496
 

+ 1 - 1
app/models/agents/jabber_agent.rb

@@ -60,7 +60,7 @@ module Agents
60 60
     end
61 61
 
62 62
     def body(event)
63
-      interpolated(event.payload)['message']
63
+      interpolated(event)['message']
64 64
     end
65 65
   end
66 66
 end

+ 2 - 2
app/models/agents/mqtt_agent.rb

@@ -106,7 +106,7 @@ module Agents
106 106
     def receive(incoming_events)
107 107
       mqtt_client.connect do |c|
108 108
         incoming_events.each do |event|
109
-          c.publish(interpolated(event.payload)['topic'], event.payload)
109
+          c.publish(interpolated(event)['topic'], event)
110 110
         end
111 111
 
112 112
         c.disconnect
@@ -136,4 +136,4 @@ module Agents
136 136
     end
137 137
 
138 138
   end
139
-end
139
+end

+ 3 - 3
app/models/agents/peak_detector_agent.rb

@@ -38,7 +38,7 @@ module Agents
38 38
         'expected_receive_period_in_days' => "2",
39 39
         'group_by_path' => "filter",
40 40
         'value_path' => "count",
41
-        'message' => "A peak was found"
41
+        'message' => "A peak of {{count}} was found in {{filter}}"
42 42
       }
43 43
     end
44 44
 
@@ -67,7 +67,7 @@ module Agents
67 67
         if newest_value > average_value + std_multiple * standard_deviation
68 68
           memory['peaks'][group] << newest_time
69 69
           memory['peaks'][group].reject! { |p| p <= newest_time - window_duration }
70
-          create_event :payload => { 'message' => interpolated(event.payload)['message'], 'peak' => newest_value, 'peak_time' => newest_time, 'grouped_by' => group.to_s }
70
+          create_event :payload => { 'message' => interpolated(event)['message'], 'peak' => newest_value, 'peak_time' => newest_time, 'grouped_by' => group.to_s }
71 71
         end
72 72
       end
73 73
     end
@@ -127,4 +127,4 @@ module Agents
127 127
       memory['data'][group].reject! { |value, time| time <= newest_time - window_duration }
128 128
     end
129 129
   end
130
-end
130
+end

+ 46 - 18
app/models/agents/post_agent.rb

@@ -5,10 +5,14 @@ module Agents
5 5
     default_schedule "never"
6 6
 
7 7
     description <<-MD
8
-      A PostAgent receives events from other agents (or runs periodically), merges those events with the contents of `payload`, and sends the results as POST (or GET) requests to a specified url.
8
+      A PostAgent receives events from other agents (or runs periodically), merges those events with the [Liquid-interpolated](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) contents of `payload`, and sends the results as POST (or GET) requests to a specified url.  To skip merging in the incoming event, but still send the interpolated payload, set `no_merge` to `true`.
9 9
 
10 10
       The `post_url` field must specify where you would like to send requests. Please include the URI scheme (`http` or `https`).
11 11
 
12
+      The `method` used can be any of `get`, `post`, `put`, `patch`, and `delete`.
13
+
14
+      By default, non-GETs will be sent with form encoding (`application/x-www-form-urlencoded`).  Change `content_type` to `json` to send JSON instead.
15
+
12 16
       The `headers` field is optional.  When present, it should be a hash of headers to send with the request.
13 17
     MD
14 18
 
@@ -17,10 +21,12 @@ module Agents
17 21
     def default_options
18 22
       {
19 23
         'post_url' => "http://www.example.com",
20
-        'expected_receive_period_in_days' => 1,
24
+        'expected_receive_period_in_days' => '1',
25
+        'content_type' => 'form',
21 26
         'method' => 'post',
22 27
         'payload' => {
23
-          'key' => 'value'
28
+          'key' => 'value',
29
+          'something' => 'the event contained {{ somekey }}'
24 30
         },
25 31
         'headers' => {}
26 32
       }
@@ -47,8 +53,12 @@ module Agents
47 53
         errors.add(:base, "if provided, payload must be a hash")
48 54
       end
49 55
 
50
-      unless %w[post get].include?(method)
51
-        errors.add(:base, "method must be 'post' or 'get'")
56
+      unless %w[post get put delete patch].include?(method)
57
+        errors.add(:base, "method must be 'post', 'get', 'put', 'delete', or 'patch'")
58
+      end
59
+
60
+      if options['no_merge'].present? && !%[true false].include?(options['no_merge'].to_s)
61
+        errors.add(:base, "if provided, no_merge must be 'true' or 'false'")
52 62
       end
53 63
 
54 64
       unless headers.is_a?(Hash)
@@ -58,7 +68,12 @@ module Agents
58 68
 
59 69
     def receive(incoming_events)
60 70
       incoming_events.each do |event|
61
-        handle (interpolated(event.payload)['payload'].presence || {}).merge(event.payload)
71
+        outgoing = interpolated(event)['payload'].presence || {}
72
+        if interpolated['no_merge'].to_s == 'true'
73
+          handle outgoing, event.payload
74
+        else
75
+          handle outgoing.merge(event.payload), event.payload
76
+        end
62 77
       end
63 78
     end
64 79
 
@@ -66,35 +81,48 @@ module Agents
66 81
       handle interpolated['payload'].presence || {}
67 82
     end
68 83
 
69
-    def generate_uri(params = nil)
70
-      uri = URI interpolated[:post_url]
84
+    def generate_uri(params = nil, payload = {})
85
+      uri = URI interpolated(payload)[:post_url]
71 86
       uri.query = URI.encode_www_form(Hash[URI.decode_www_form(uri.query || '')].merge(params)) if params
72 87
       uri
73 88
     end
74 89
 
75 90
     private
76 91
 
77
-    def handle(data)
92
+    def handle(data, payload = {})
78 93
       if method == 'post'
79
-        post_data(data)
94
+        post_data(data, payload, Net::HTTP::Post)
95
+      elsif method == 'put'
96
+        post_data(data, payload, Net::HTTP::Put)
97
+      elsif method == 'delete'
98
+        post_data(data, payload, Net::HTTP::Delete)
99
+      elsif method == 'patch'
100
+        post_data(data, payload, Net::HTTP::Patch)
80 101
       elsif method == 'get'
81
-        get_data(data)
102
+        get_data(data, payload)
82 103
       else
83 104
         error "Invalid method '#{method}'"
84 105
       end
85 106
     end
86 107
 
87
-    def post_data(data)
88
-      uri = generate_uri
89
-      req = Net::HTTP::Post.new(uri.request_uri, headers)
90
-      req.form_data = data
108
+    def post_data(data, payload, request_type = Net::HTTP::Post)
109
+      uri = generate_uri(nil, payload)
110
+      req = request_type.new(uri.request_uri, headers)
111
+
112
+      if interpolated(payload)['content_type'] == 'json'
113
+        req.set_content_type('application/json', 'charset' => 'utf-8')
114
+        req.body = data.to_json
115
+      else
116
+        req.form_data = data
117
+      end
118
+
91 119
       Net::HTTP.start(uri.hostname, uri.port, :use_ssl => uri.scheme == "https") { |http| http.request(req) }
92 120
     end
93 121
 
94
-    def get_data(data)
95
-      uri = generate_uri(data)
122
+    def get_data(data, payload)
123
+      uri = generate_uri(data, payload)
96 124
       req = Net::HTTP::Get.new(uri.request_uri, headers)
97 125
       Net::HTTP.start(uri.hostname, uri.port, :use_ssl => uri.scheme == "https") { |http| http.request(req) }
98 126
     end
99 127
   end
100
-end
128
+end

+ 1 - 1
app/models/agents/pushbullet_agent.rb

@@ -49,7 +49,7 @@ module Agents
49 49
     private
50 50
 
51 51
     def query_options(event)
52
-      mo = interpolated(event.payload)
52
+      mo = interpolated(event)
53 53
       {
54 54
         :basic_auth => {:username => mo[:api_key], :password => ''},
55 55
         :body => {:device_iden => mo[:device_id], :title => mo[:title], :body => mo[:body], :type => 'note'}

+ 1 - 1
app/models/agents/pushover_agent.rb

@@ -58,7 +58,7 @@ module Agents
58 58
 
59 59
     def receive(incoming_events)
60 60
       incoming_events.each do |event|
61
-        payload_interpolated = interpolated(event.payload)
61
+        payload_interpolated = interpolated(event)
62 62
         message = (event.payload['message'].presence || event.payload['text'].presence || payload_interpolated['message']).to_s
63 63
         if message.present?
64 64
           post_params = {

+ 89 - 0
app/models/agents/rss_agent.rb

@@ -0,0 +1,89 @@
1
+require 'rss'
2
+require 'feed-normalizer'
3
+
4
+module Agents
5
+  class RssAgent < Agent
6
+    include WebRequestConcern
7
+
8
+    cannot_receive_events!
9
+    default_schedule "every_1d"
10
+
11
+    description do
12
+      <<-MD
13
+        This Agent consumes RSS feeds and emits events when they change.
14
+
15
+        (If you want to *output* an RSS feed, use the DataOutputAgent.  Also, you can technically parse RSS and XML feeds
16
+        with the WebsiteAgent as well.  See [this example](https://github.com/cantino/huginn/wiki/Agent-configuration-examples#itunes-trailers).)
17
+
18
+        Options:
19
+
20
+          * `url` - The URL of the RSS feed.
21
+          * `clean` - Attempt to use [feed-normalizer](https://github.com/aasmith/feed-normalizer)'s' `clean!` method to cleanup HTML in the feed.  Set to `true` to use.
22
+          * `expected_update_period_in_days` - How often you expect this RSS feed to change.  If more than this amount of time passes without an update, the Agent will mark itself as not working.
23
+      MD
24
+    end
25
+
26
+    def default_options
27
+      {
28
+        'expected_update_period_in_days' => "5",
29
+        'clean' => 'false',
30
+        'url' => "https://github.com/cantino/huginn/commits/master.atom"
31
+      }
32
+    end
33
+
34
+    def working?
35
+      event_created_within?((interpolated['expected_update_period_in_days'].presence || 10).to_i) && !recent_error_logs?
36
+    end
37
+
38
+    def validate_options
39
+      errors.add(:base, "url is required") unless options['url'].present?
40
+
41
+      unless options['expected_update_period_in_days'].present? && options['expected_update_period_in_days'].to_i > 0
42
+        errors.add(:base, "Please provide 'expected_update_period_in_days' to indicate how many days can pass without an update before this Agent is considered to not be working")
43
+      end
44
+
45
+      validate_web_request_options!
46
+    end
47
+
48
+    def check
49
+      response = faraday.get(interpolated['url'])
50
+      if response.success?
51
+        feed = FeedNormalizer::FeedNormalizer.parse(response.body)
52
+        feed.clean! if interpolated['clean'] == 'true'
53
+        created_event_count = 0
54
+        feed.entries.each do |entry|
55
+          if check_and_track(entry.id)
56
+            created_event_count += 1
57
+            create_event(:payload => {
58
+              :id => entry.id,
59
+              :date_published => entry.date_published,
60
+              :last_updated => entry.last_updated,
61
+              :urls => entry.urls,
62
+              :description => entry.description,
63
+              :content => entry.content,
64
+              :title => entry.title,
65
+              :authors => entry.authors,
66
+              :categories => entry.categories
67
+            })
68
+          end
69
+        end
70
+        log "Fetched #{interpolated['url']} and created #{created_event_count} event(s)."
71
+      else
72
+        error "Failed to fetch #{interpolated['url']}: #{response.inspect}"
73
+      end
74
+    end
75
+
76
+    protected
77
+
78
+    def check_and_track(entry_id)
79
+      memory['seen_ids'] ||= []
80
+      if memory['seen_ids'].include?(entry_id)
81
+        false
82
+      else
83
+        memory['seen_ids'].unshift entry_id
84
+        memory['seen_ids'].pop if memory['seen_ids'].length > 500
85
+        true
86
+      end
87
+    end
88
+  end
89
+end

+ 2 - 2
app/models/agents/shell_command_agent.rb

@@ -61,7 +61,7 @@ module Agents
61 61
 
62 62
     def receive(incoming_events)
63 63
       incoming_events.each do |event|
64
-        handle(interpolated(event.payload), event)
64
+        handle(interpolated(event), event)
65 65
       end
66 66
     end
67 67
 
@@ -109,4 +109,4 @@ module Agents
109 109
       [result, errors, exit_status]
110 110
     end
111 111
   end
112
-end
112
+end

+ 1 - 1
app/models/agents/slack_agent.rb

@@ -57,7 +57,7 @@ module Agents
57 57
 
58 58
     def receive(incoming_events)
59 59
       incoming_events.each do |event|
60
-        opts = interpolated(event.payload)
60
+        opts = interpolated(event)
61 61
         slack_notifier.ping opts[:message], channel: opts[:channel], username: opts[:username]
62 62
       end
63 63
     end

+ 1 - 1
app/models/agents/translation_agent.rb

@@ -66,7 +66,7 @@ module Agents
66 66
       access_token = JSON.parse(response.body)["access_token"]
67 67
       incoming_events.each do |event|
68 68
         translated_event = {}
69
-        opts = interpolated(event.payload)
69
+        opts = interpolated(event)
70 70
         opts['content'].each_pair do |key, value|
71 71
           translated_event[key] = translate(value.first, opts['to'], access_token)
72 72
         end

+ 2 - 2
app/models/agents/trigger_agent.rb

@@ -57,7 +57,7 @@ module Agents
57 57
     def receive(incoming_events)
58 58
       incoming_events.each do |event|
59 59
 
60
-        opts = interpolated(event.payload)
60
+        opts = interpolated(event)
61 61
 
62 62
         match = opts['rules'].all? do |rule|
63 63
           value_at_path = Utils.value_at(event['payload'], rule['path'])
@@ -105,4 +105,4 @@ module Agents
105 105
       interpolated['keep_event'] == 'true'
106 106
     end
107 107
   end
108
-end
108
+end

+ 3 - 3
app/models/agents/twilio_agent.rb

@@ -44,13 +44,13 @@ module Agents
44 44
       incoming_events.each do |event|
45 45
         message = (event.payload['message'].presence || event.payload['text'].presence || event.payload['sms'].presence).to_s
46 46
         if message.present?
47
-          if interpolated(event.payload)['receive_call'].to_s == 'true'
47
+          if interpolated(event)['receive_call'].to_s == 'true'
48 48
             secret = SecureRandom.hex 3
49 49
             memory['pending_calls'][secret] = message
50 50
             make_call secret
51 51
           end
52 52
 
53
-          if interpolated(event.payload)['receive_text'].to_s == 'true'
53
+          if interpolated(event)['receive_text'].to_s == 'true'
54 54
             message = message.slice 0..160
55 55
             send_message message
56 56
           end
@@ -86,4 +86,4 @@ module Agents
86 86
       end
87 87
     end
88 88
   end
89
-end
89
+end

+ 2 - 2
app/models/agents/twitter_publish_agent.rb

@@ -37,7 +37,7 @@ module Agents
37 37
         incoming_events = incoming_events.first(20)
38 38
       end
39 39
       incoming_events.each do |event|
40
-        tweet_text = interpolated(event.payload)['message']
40
+        tweet_text = interpolated(event)['message']
41 41
         begin
42 42
           tweet = publish_tweet tweet_text
43 43
           create_event :payload => {
@@ -63,4 +63,4 @@ module Agents
63 63
       twitter.update(text)
64 64
     end
65 65
   end
66
-end
66
+end

+ 24 - 73
app/models/agents/website_agent.rb

@@ -5,6 +5,7 @@ require 'date'
5 5
 
6 6
 module Agents
7 7
   class WebsiteAgent < Agent
8
+    include WebRequestConcern
8 9
 
9 10
     default_schedule "every_12h"
10 11
 
@@ -22,14 +23,16 @@ module Agents
22 23
 
23 24
       To tell the Agent how to parse the content, specify `extract` as a hash with keys naming the extractions and values of hashes.
24 25
 
25
-      When parsing HTML or XML, these sub-hashes specify how to extract with either a `css` CSS selector or a `xpath` XPath expression and either `"text": true` or `attr` pointing to an attribute name to grab.  An example:
26
+      When parsing HTML or XML, these sub-hashes specify how each extraction should be done.  The Agent first selects a node set from the document for each extraction key by evaluating either a CSS selector in `css` or an XPath expression in `xpath`.  It then evaluates an XPath expression in `value` on each node in the node set, converting the result into string.  Here's an example:
26 27
 
27 28
           "extract": {
28
-            "url": { "css": "#comic img", "attr": "src" },
29
-            "title": { "css": "#comic img", "attr": "title" },
30
-            "body_text": { "css": "div.main", "text": true }
29
+            "url": { "css": "#comic img", "value": "@src" },
30
+            "title": { "css": "#comic img", "value": "@title" },
31
+            "body_text": { "css": "div.main", "value": ".//text()" }
31 32
           }
32 33
 
34
+      "@_attr_" is the XPath expression to extract the value of an attribute named _attr_ from a node, and ".//text()" is to extract all the enclosed texts.  You can also use [XPath functions](http://www.w3.org/TR/xpath/#section-String-Functions) like `normalize-space` to strip and squeeze whitespace, `substring-after` to extract part of a text, and `translate` to remove comma from a formatted number, etc.  Note that these functions take a string, not a node set, so what you may think would be written as `normalize-text(.//text())` should actually be `normalize-text(.)`.
35
+
33 36
       When parsing JSON, these sub-hashes specify [JSONPaths](http://goessner.net/articles/JsonPath/) to the values that you care about.  For example:
34 37
 
35 38
           "extract": {
@@ -69,9 +72,9 @@ module Agents
69 72
           'type' => "html",
70 73
           'mode' => "on_change",
71 74
           'extract' => {
72
-            'url' => { 'css' => "#comic img", 'attr' => "src" },
73
-            'title' => { 'css' => "#comic img", 'attr' => "alt" },
74
-            'hovertext' => { 'css' => "#comic img", 'attr' => "title" }
75
+            'url' => { 'css' => "#comic img", 'value' => "@src" },
76
+            'title' => { 'css' => "#comic img", 'value' => "@alt" },
77
+            'hovertext' => { 'css' => "#comic img", 'value' => "@title" }
75 78
           }
76 79
       }
77 80
     end
@@ -109,19 +112,7 @@ module Agents
109 112
         end
110 113
       end
111 114
 
112
-      if options['user_agent'].present?
113
-        errors.add(:base, "user_agent must be a string") unless options['user_agent'].is_a?(String)
114
-      end
115
-
116
-      unless headers.is_a?(Hash)
117
-        errors.add(:base, "if provided, headers must be a hash")
118
-      end
119
-
120
-      begin
121
-        basic_auth_credentials()
122
-      rescue => e
123
-        errors.add(:base, e.message)
124
-      end
115
+      validate_web_request_options!
125 116
     end
126 117
 
127 118
     def check
@@ -157,25 +148,27 @@ module Agents
157 148
                 when css = extraction_details['css']
158 149
                   nodes = doc.css(css)
159 150
                 when xpath = extraction_details['xpath']
151
+                  doc.remove_namespaces! # ignore xmlns, useful when parsing atom feeds
160 152
                   nodes = doc.xpath(xpath)
161 153
                 else
162 154
                   error '"css" or "xpath" is required for HTML or XML extraction'
163 155
                   return
164 156
                 end
165
-                unless Nokogiri::XML::NodeSet === nodes
157
+                case nodes
158
+                when Nokogiri::XML::NodeSet
159
+                  result = nodes.map { |node|
160
+                    case value = node.xpath(extraction_details['value'])
161
+                    when Float
162
+                      # Node#xpath() returns any numeric value as float;
163
+                      # convert it to integer as appropriate.
164
+                      value = value.to_i if value.to_i == value
165
+                    end
166
+                    value.to_s
167
+                  }
168
+                else
166 169
                   error "The result of HTML/XML extraction was not a NodeSet"
167 170
                   return
168 171
                 end
169
-                result = nodes.map { |node|
170
-                  if extraction_details['attr']
171
-                    node.attr(extraction_details['attr'])
172
-                  elsif extraction_details['text']
173
-                    node.text()
174
-                  else
175
-                    error '"attr" or "text" is required on HTML or XML extraction patterns'
176
-                    return
177
-                  end
178
-                }
179 172
                 log "Extracting #{extraction_type} at #{xpath || css}: #{result}"
180 173
               end
181 174
               output[name] = result
@@ -290,47 +283,5 @@ module Agents
290 283
         false
291 284
       end
292 285
     end
293
-
294
-    def faraday
295
-      @faraday ||= Faraday.new { |builder|
296
-        builder.headers = headers if headers.length > 0
297
-
298
-        if (user_agent = interpolated['user_agent']).present?
299
-          builder.headers[:user_agent] = user_agent
300
-        end
301
-
302
-        builder.use FaradayMiddleware::FollowRedirects
303
-        builder.request :url_encoded
304
-        if userinfo = basic_auth_credentials()
305
-          builder.request :basic_auth, *userinfo
306
-        end
307
-
308
-        case backend = faraday_backend
309
-        when :typhoeus
310
-          require 'typhoeus/adapters/faraday'
311
-        end
312
-        builder.adapter backend
313
-      }
314
-    end
315
-
316
-    def faraday_backend
317
-      ENV.fetch('FARADAY_HTTP_BACKEND', 'typhoeus').to_sym
318
-    end
319
-
320
-    def basic_auth_credentials
321
-      case value = interpolated['basic_auth']
322
-      when nil, ''
323
-        return nil
324
-      when Array
325
-        return value if value.size == 2
326
-      when /:/
327
-        return value.split(/:/, 2)
328
-      end
329
-      raise "bad value for basic_auth: #{value.inspect}"
330
-    end
331
-
332
-    def headers
333
-      interpolated['headers'].presence || {}
334
-    end
335 286
   end
336 287
 end

+ 2 - 2
app/models/agents/weibo_publish_agent.rb

@@ -47,7 +47,7 @@ module Agents
47 47
         incoming_events = incoming_events.first(20)
48 48
       end
49 49
       incoming_events.each do |event|
50
-        tweet_text = Utils.value_at(event.payload, interpolated(event.payload)['message_path'])
50
+        tweet_text = Utils.value_at(event.payload, interpolated(event)['message_path'])
51 51
         if event.agent.type == "Agents::TwitterUserAgent"
52 52
           tweet_text = unwrap_tco_urls(tweet_text, event.payload)
53 53
         end
@@ -83,4 +83,4 @@ module Agents
83 83
     end
84 84
 
85 85
   end
86
-end
86
+end

+ 24 - 0
app/models/event.rb

@@ -5,6 +5,7 @@ require 'json_serialized_field'
5 5
 # fields.
6 6
 class Event < ActiveRecord::Base
7 7
   include JSONSerializedField
8
+  include LiquidDroppable
8 9
 
9 10
   attr_accessible :lat, :lng, :payload, :user_id, :user, :expires_at
10 11
 
@@ -41,3 +42,26 @@ class Event < ActiveRecord::Base
41 42
     Agent.receive!(:only_receivers => propagate_ids) unless propagate_ids.empty?
42 43
   end
43 44
 end
45
+
46
+class EventDrop
47
+  def initialize(event, payload = event.payload)
48
+    super(event)
49
+    @payload = payload
50
+  end
51
+
52
+  def before_method(key)
53
+    if @payload.key?(key)
54
+      @payload[key]
55
+    else
56
+      case key
57
+      when 'agent'
58
+        @object.agent
59
+      end
60
+    end
61
+  end
62
+
63
+  def each(&block)
64
+    return to_enum(__method__) unless block
65
+    @payload.each(&block)
66
+  end
67
+end

+ 1 - 0
app/views/agents/_form.html.erb

@@ -86,6 +86,7 @@
86 86
         <div class="col-md-12">
87 87
           <div class="form-group">
88 88
             <%= f.label :options %>
89
+            <span class="glyphicon glyphicon-question-sign hover-help" data-content="In this JSON hash, interpolation is available in almost all values using the Liquid templating language.<p>Available template variables include the following:<dl><dt><code>message</code>, <code>url</code>, etc.</dt><dd>Refers to the corresponding key's value of each incoming event's payload.</dd><dt><code>agent</code></dt><dd>Refers to the agent that created each incoming event.  It has attributes like <code>type</code>, <code>name</code> and <code>options</code>, so <code>{{agent.type}}</code> will expand to <code>WebsiteAgent</code> if an incoming event is created by that agent.</dd></dl></p><p>To access user credentials, use the <code>credential</code> tag like this: <code>{% credential <em>bare_key_name</em> %}</code></p>"></span>
89 90
             <textarea rows="15" id="agent_options" name="agent[options]" class="form-control live-json-editor <%= (@agent.new_record? && @agent.options == {}) ? "showing-default" : "" %>">
90 91
               <%= Utils.jsonify((@agent.new_record? && @agent.options == {}) ? @agent.default_options : @agent.options) %>
91 92
             </textarea>

+ 5 - 0
app/views/agents/show.html.erb

@@ -116,6 +116,11 @@
116 116
                   None
117 117
                 <% end %>
118 118
               </p>
119
+
120
+              <p>
121
+                <b>Propagate immediately:</b>
122
+                <%= yes_no @agent.propagate_immediately %>
123
+              </p>
119 124
             <% end %>
120 125
 
121 126
             <% if @agent.can_create_events? %>

+ 160 - 0
bin/setup_heroku

@@ -0,0 +1,160 @@
1
+#!/usr/bin/env ruby
2
+require 'open3'
3
+require 'io/console'
4
+
5
+unless `which heroku` =~ /heroku/
6
+  puts "It looks like the heroku command line tool hasn't been installed yet.  Please install"
7
+  puts "the Heroku Toolbelt from https://toolbelt.heroku.com, run 'heroku auth:login', and then"
8
+  puts "run this script again."
9
+  exit 1
10
+end
11
+
12
+def capture(cmd, opts = {})
13
+  o, s = Open3.capture2e(cmd, opts)
14
+  o.strip
15
+end
16
+
17
+def ask(question, opts = {})
18
+  print question + " "
19
+  STDOUT.flush
20
+  (opts[:noecho] ? STDIN.noecho(&:gets) : gets).strip
21
+end
22
+
23
+def nag(question, opts = {})
24
+  answer = ''
25
+  while answer.length == 0
26
+    answer = ask(question, opts)
27
+  end
28
+  answer
29
+end
30
+
31
+def yes?(question)
32
+  ask(question + " (y/n)") =~ /^y/i
33
+end
34
+
35
+def grab_heroku_config!
36
+  config_data = capture("heroku config -s")
37
+  $config = {}
38
+  if config_data !~ /has no config vars/
39
+    config_data.split("\n").map do |line|
40
+      next if line =~ /^\s*(#|$)/ # skip comments and empty lines
41
+      first_equal_sign = line.index('=')
42
+      $config[line.slice(0, first_equal_sign)] = line.slice(first_equal_sign + 1, line.length)
43
+    end
44
+  end
45
+end
46
+
47
+def set_value(key, value, options = {})
48
+  if $config[key].nil? || $config[key] == '' || ($config[key] != value && options[:force] != false)
49
+    puts "Setting #{key} to #{value}" unless options[:silent]
50
+    puts capture("heroku config:set #{key}=#{value}")
51
+  end
52
+end
53
+
54
+unless File.exists?(File.expand_path("~/.netrc")) && File.read(File.expand_path("~/.netrc")) =~ /heroku/
55
+  puts "It looks like you need to log in to Heroku.  Please run 'heroku auth:login' before continuing."
56
+  exit 1
57
+end
58
+
59
+puts "Welcome #{`heroku auth:whoami`.strip}!  It looks like you're logged into Heroku."
60
+puts
61
+
62
+info = capture("heroku info")
63
+if info =~ /No app specified/i
64
+  puts "It looks like you don't have a Heroku app set up yet for this repo."
65
+  puts "You can either exit now and run 'heroku create', or I can do it for you."
66
+  if yes?("Would you like me to create a Heroku app for you now in this repo?")
67
+    puts `heroku create`
68
+    info = capture("heroku info")
69
+  else
70
+    puts "Okay, exiting so you can do it."
71
+    exit 0
72
+  end
73
+end
74
+
75
+app_name = info.scan(/http:\/\/([\w\d-]+)\.herokuapp\.com/).flatten.first
76
+
77
+unless yes?("Your Heroku app name is #{app_name}.  Is this correct?")
78
+  puts "Well, then I'm not sure what to do here, sorry."
79
+  exit 1
80
+end
81
+
82
+grab_heroku_config!
83
+
84
+if $config.length > 0
85
+  puts
86
+  puts "Your current Heroku config:"
87
+  $config.each do |key, value|
88
+    puts '  ' + key + ' ' * (25 - [key.length, 25].min) + '= ' + value
89
+  end
90
+end
91
+
92
+unless $config['APP_SECRET_TOKEN']
93
+  puts "Setting up APP_SECRET_TOKEN..."
94
+  puts capture("heroku config:set APP_SECRET_TOKEN=`rake secret`")
95
+end
96
+
97
+set_value 'BUILDPACK_URL', "https://github.com/ddollar/heroku-buildpack-multi.git"
98
+set_value 'PROCFILE_PATH', "deployment/heroku/Procfile.heroku", force: false
99
+set_value 'ON_HEROKU', "true"
100
+set_value 'FORCE_SSL', "true"
101
+set_value 'DOMAIN', "#{app_name}.herokuapp.com", force: false
102
+
103
+unless $config['INVITATION_CODE']
104
+  puts "You need to set an invitation code for your Huginn instance.  If you plan to share this instance, you will"
105
+  puts "tell this code to anyone who you'd like to invite.  If you won't share it, then just set this to something"
106
+  puts "that people will not guess."
107
+
108
+  invitation_code = nag("What code would you like to use?")
109
+  set_value 'INVITATION_CODE', invitation_code
110
+end
111
+
112
+unless $config['SMTP_DOMAIN'] && $config['SMTP_USER_NAME'] && $config['SMTP_PASSWORD'] && $config['SMTP_SERVER'] && $config['EMAIL_FROM_ADDRESS']
113
+  puts "Okay, let's setup outgoing email settings.  The simplest solution is to use the free sendgrid Heroku addon."
114
+  puts "If you'd like to use your own server, or your Gmail account, please see .env.example and set"
115
+  puts "SMTP_DOMAIN, SMTP_USER_NAME, SMTP_PASSWORD, and SMTP_SERVER with 'heroku config:set'."
116
+  if yes?("Should I enable the free sendgrid addon?")
117
+    puts capture("heroku addons:add sendgrid")
118
+
119
+    set_value 'SMTP_SERVER', "smtp.sendgrid.net", silent: true
120
+    set_value 'SMTP_DOMAIN', "heroku.com", silent: true
121
+
122
+    grab_heroku_config!
123
+    set_value 'SMTP_USER_NAME', $config['SENDGRID_USERNAME'], silent: true
124
+    set_value 'SMTP_PASSWORD', $config['SENDGRID_PASSWORD'], silent: true
125
+  else
126
+    puts "Okay, you'll need to set SMTP_DOMAIN, SMTP_USER_NAME, SMTP_PASSWORD, and SMTP_SERVER with 'heroku config:set' manually."
127
+  end
128
+
129
+  unless $config['EMAIL_FROM_ADDRESS']
130
+    email = nag("What email address would you like email to appear to be sent from?")
131
+    set_value 'EMAIL_FROM_ADDRESS', email
132
+  end
133
+end
134
+
135
+branch = capture("git rev-parse --abbrev-ref HEAD")
136
+if yes?("Should I push your current branch (#{branch}) to heroku?")
137
+  puts "This may take a moment..."
138
+  puts capture("git push heroku #{branch}:master -f")
139
+
140
+  puts "Running database migrations..."
141
+  puts capture("heroku run rake db:migrate")
142
+
143
+  puts
144
+  puts
145
+  puts "I can make an admin user on your new Huginn instance and setup some example Agents."
146
+  if yes?("Should I create a new admin user and some example Agents?")
147
+    seed_email = nag "Okay, what is your email address?"
148
+    seed_username = nag "And what username would you like to login as?"
149
+    seed_password = nag "Finally, what password would you like to use?", noecho: true
150
+    puts "\nJust a moment..."
151
+
152
+    capture("heroku run rake db:seed SEED_EMAIL=#{seed_email} SEED_USERNAME=#{seed_username} SEED_PASSWORD=#{seed_password}")
153
+    puts
154
+    puts
155
+    puts "Okay, you should be all set!  Visit https://#{app_name}.herokuapp.com and login as '#{seed_username}' with your password."
156
+  end
157
+end
158
+
159
+puts
160
+puts "Done!"

+ 2 - 2
config/initializers/delayed_job.rb

@@ -5,5 +5,5 @@ Delayed::Worker.read_ahead = 5
5 5
 Delayed::Worker.default_priority = 10
6 6
 Delayed::Worker.delay_jobs = !Rails.env.test?
7 7
 
8
-Delayed::Worker.logger = Logger.new(Rails.root.join('log', 'delayed_job.log'))
9
-Delayed::Worker.logger.level = Logger::DEBUG
8
+# Delayed::Worker.logger = Logger.new(Rails.root.join('log', 'delayed_job.log'))
9
+# Delayed::Worker.logger.level = Logger::DEBUG

+ 10 - 0
config/initializers/silence_worker_status_logger.rb

@@ -0,0 +1,10 @@
1
+Rails::Rack::Logger.class_eval do
2
+  def call_with_silence_worker_status(env)
3
+    previous_level = Rails.logger.level
4
+    Rails.logger.level = Logger::ERROR if env['PATH_INFO'] =~ %r{^/worker_status}
5
+    call_without_silence_worker_status(env)
6
+  ensure
7
+    Rails.logger.level = previous_level
8
+  end
9
+  alias_method_chain :call, :silence_worker_status
10
+end

+ 25 - 0
db/migrate/20140505201716_migrate_agents_to_liquid_templating.rb

@@ -1,4 +1,29 @@
1 1
 class MigrateAgentsToLiquidTemplating < ActiveRecord::Migration
2
+  class Agent < ActiveRecord::Base
3
+    include JSONSerializedField
4
+    json_serialize :options, :memory
5
+  end
6
+  class Agents::HipchatAgent < Agent
7
+  end
8
+  class Agents::EventFormattingAgent < Agent
9
+  end
10
+  class Agents::PushbulletAgent < Agent
11
+  end
12
+  class Agents::JabberAgent < Agent
13
+  end
14
+  class Agents::DataOutputAgent < Agent
15
+  end
16
+  class Agents::TranslationAgent < Agent
17
+  end
18
+  class Agents::TwitterPublishAgent < Agent
19
+  end
20
+  class Agents::TriggerAgent < Agent
21
+  end
22
+  class Agents::PeakDetectorAgent < Agent
23
+  end
24
+  class Agents::HumanTaskAgent < Agent
25
+  end
26
+
2 27
   def up
3 28
     Agent.where(:type => 'Agents::HipchatAgent').each do |agent|
4 29
       LiquidMigrator.convert_all_agent_options(agent)

+ 21 - 0
db/migrate/20140722131220_convert_efa_skip_agent.rb

@@ -0,0 +1,21 @@
1
+class ConvertEfaSkipAgent < ActiveRecord::Migration
2
+  def up
3
+    Agent.where(type: 'Agents::EventFormattingAgent').each do |agent|
4
+      agent.options_will_change!
5
+      unless agent.options.delete('skip_agent').to_s == 'true'
6
+        agent.options['instructions'] = {
7
+          'agent' => '{{agent.type}}'
8
+        }.update(agent.options['instructions'] || {})
9
+      end
10
+      agent.save!
11
+    end
12
+  end
13
+
14
+  def down
15
+    Agent.where(type: 'Agents::EventFormattingAgent').each do |agent|
16
+      agent.options_will_change!
17
+      agent.options['skip_agent'] = (agent.options['instructions'] || {})['agent'] == '{{agent.type}}'
18
+      agent.save!
19
+    end
20
+  end
21
+end

+ 30 - 0
db/migrate/20140723110551_adopt_xpath_in_website_agent.rb

@@ -0,0 +1,30 @@
1
+class AdoptXpathInWebsiteAgent < ActiveRecord::Migration
2
+  class Agent < ActiveRecord::Base
3
+    include JSONSerializedField
4
+    json_serialize :options
5
+  end
6
+
7
+  def up
8
+    Agent.where(type: 'Agents::WebsiteAgent').each do |agent|
9
+      extract = agent.options['extract']
10
+      next unless extract.is_a?(Hash) && extract.all? { |name, detail|
11
+        detail.key?('xpath') || detail.key?('css')
12
+      }
13
+
14
+      agent.options_will_change!
15
+      agent.options['extract'].each { |name, extraction|
16
+        case
17
+        when extraction.delete('text')
18
+          extraction['value'] = './/text()'
19
+        when attr = extraction.delete('attr')
20
+          extraction['value'] = "@#{attr}"
21
+        end
22
+      }
23
+      agent.save!
24
+    end
25
+  end
26
+
27
+  def down
28
+    raise ActiveRecord::IrreversibleMigration, "Cannot revert this migration"
29
+  end
30
+end

+ 9 - 9
db/seeds.rb

@@ -1,10 +1,10 @@
1 1
 # This file should contain all the record creation needed to seed the database with its default values.
2 2
 # The data can then be loaded with the rake db:seed (or created alongside the db with db:setup).
3 3
 
4
-user = User.find_or_initialize_by(:email => "admin@example.com")
5
-user.username = "admin"
6
-user.password = "password"
7
-user.password_confirmation = "password"
4
+user = User.find_or_initialize_by(:email => ENV['SEED_EMAIL'] || "admin@example.com")
5
+user.username = ENV['SEED_USERNAME'] || "admin"
6
+user.password = ENV['SEED_PASSWORD'] || "password"
7
+user.password_confirmation = ENV['SEED_PASSWORD'] || "password"
8 8
 user.invitation_code = User::INVITATION_CODES.first
9 9
 user.admin = true
10 10
 user.save!
@@ -31,9 +31,9 @@ unless user.agents.where(:name => "XKCD Source").exists?
31 31
                            'mode' => "on_change",
32 32
                            'expected_update_period_in_days' => 5,
33 33
                            'extract' => {
34
-                               'url' => { 'css' => "#comic img", 'attr' => "src" },
35
-                               'title' => { 'css' => "#comic img", 'attr' => "alt" },
36
-                               'hovertext' => { 'css' => "#comic img", 'attr' => "title" }
34
+                               'url' => { 'css' => "#comic img", 'value' => "@src" },
35
+                               'title' => { 'css' => "#comic img", 'value' => "@alt" },
36
+                               'hovertext' => { 'css' => "#comic img", 'value' => "@title" }
37 37
                            }
38 38
                        }).save!
39 39
 end
@@ -47,8 +47,8 @@ unless user.agents.where(:name => "iTunes Trailer Source").exists?
47 47
                            'type' => "xml",
48 48
                            'expected_update_period_in_days' => 5,
49 49
                            'extract' => {
50
-                               'title' => { 'css' => "item title", 'text' => true},
51
-                               'url' => { 'css' => "item link", 'text' => true}
50
+                               'title' => { 'css' => "item title", 'value' => ".//text()"},
51
+                               'url' => { 'css' => "item link", 'value' => ".//text()"}
52 52
                            }
53 53
                        }).save!
54 54
 end

+ 4 - 0
deployment/heroku/Procfile.heroku

@@ -0,0 +1,4 @@
1
+# This Procfile is intended for Heroku, and is detected by the Gemfile.  DO NOT REMOVE THIS LINE!
2
+
3
+# deployment/heroku/unicorn.rb is a special Unicorn config file that also spawns workers.
4
+web: bundle exec unicorn -p $PORT -c ./deployment/heroku/unicorn.rb

+ 51 - 0
deployment/heroku/unicorn.rb

@@ -0,0 +1,51 @@
1
+require "net/http"
2
+
3
+worker_processes Integer(ENV["WEB_CONCURRENCY"] || 2)
4
+timeout 15
5
+preload_app true
6
+
7
+# Note that this will only work correctly when running Heroku with ONE web worker.
8
+# If you want to run more than one, use the standard Huginn Procfile instead, with separate web and job entries.
9
+# You'll need to set the Heroku config variable PROCFILE_PATH to 'Procfile'.
10
+Thread.new do
11
+  worker_pid = nil
12
+  while true
13
+    if worker_pid.nil?
14
+      worker_pid = spawn("bundle exec rails runner bin/threaded.rb")
15
+      puts "New threaded worker PID: #{worker_pid}"
16
+    end
17
+
18
+    sleep 45
19
+
20
+    if ENV['DOMAIN']
21
+      force_ssl = ENV['FORCE_SSL'].present? && ENV['FORCE_SSL'] == 'true'
22
+      Net::HTTP.get_response(URI((force_ssl ? "https://" : "http://") + ENV['DOMAIN']))
23
+    end
24
+
25
+    begin
26
+      Process.getpgid worker_pid
27
+    rescue Errno::ESRCH
28
+      # No longer running
29
+      worker_pid = nil
30
+    end
31
+  end
32
+end
33
+
34
+before_fork do |server, worker|
35
+  Signal.trap 'TERM' do
36
+    puts 'Unicorn master intercepting TERM and sending myself QUIT instead'
37
+    Process.kill 'QUIT', Process.pid
38
+  end
39
+
40
+  defined?(ActiveRecord::Base) and
41
+    ActiveRecord::Base.connection.disconnect!
42
+end
43
+
44
+after_fork do |server, worker|
45
+  Signal.trap 'TERM' do
46
+    puts 'Unicorn worker intercepting TERM and doing nothing. Wait for master to send QUIT'
47
+  end
48
+
49
+  defined?(ActiveRecord::Base) and
50
+    ActiveRecord::Base.establish_connection
51
+end

+ 67 - 0
lib/google_calendar.rb

@@ -0,0 +1,67 @@
1
+require "google/api_client"
2
+
3
+class GoogleCalendar
4
+
5
+  def initialize(config, logger)
6
+    @config = config
7
+    @key = Google::APIClient::PKCS12.load_key(@config['google']['key_file'], @config['google']['key_secret'])
8
+    @client = Google::APIClient.new(application_name: "Huginn", application_version: "0.0.1")
9
+    @client.retries = 2
10
+    @logger ||= logger
11
+
12
+    @calendar = @client.discovered_api('calendar','v3')
13
+
14
+    @logger.info("Setup")
15
+    @logger.debug @calendar.inspect
16
+  end
17
+
18
+  def auth_as
19
+    @client.authorization = Signet::OAuth2::Client.new({
20
+      token_credential_uri: 'https://accounts.google.com/o/oauth2/token',
21
+      audience:             'https://accounts.google.com/o/oauth2/token',
22
+      scope:                'https://www.googleapis.com/auth/calendar',
23
+      issuer:               @config['google']['service_account_email'],
24
+      signing_key:          @key
25
+    });
26
+
27
+    @client.authorization.fetch_access_token!
28
+  end
29
+
30
+  # who - String: email of user to add event
31
+  # details - JSON String: see https://developers.google.com/google-apps/calendar/v3/reference/events/insert
32
+  def publish_as(who, details)
33
+    auth_as
34
+
35
+    @logger.info("Attempting to create event for " + who)
36
+    @logger.debug details.to_yaml
37
+
38
+    ret = @client.execute(
39
+      api_method: @calendar.events.insert,
40
+      parameters: {'calendarId' => who, 'sendNotifications' => true},
41
+      body: details.to_json,
42
+      headers: {'Content-Type' => 'application/json'}
43
+    )
44
+    @logger.debug ret.to_yaml
45
+    ret
46
+  end
47
+
48
+  def events_as(who, date)
49
+    auth_as
50
+
51
+    date ||= Date.today
52
+
53
+    @logger.info("Attempting to receive events for "+who)
54
+    @logger.debug details.to_yaml
55
+
56
+    ret = @client.execute(
57
+      api_method: @calendar.events.list,
58
+      parameters: {'calendarId' => who, 'sendNotifications' => true},
59
+      body: details.to_json,
60
+      headers: {'Content-Type' => 'application/json'}
61
+    )
62
+
63
+    @logger.debug ret.to_yaml
64
+    ret    
65
+  end
66
+
67
+end

+ 435 - 0
spec/cassettes/Agents_GoogleCalendarPublishAgent/_receive/should_publish_any_payload_it_receives.yml

@@ -0,0 +1,435 @@
1
+---
2
+http_interactions:
3
+- request:
4
+    method: get
5
+    uri: https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest
6
+    body:
7
+      encoding: UTF-8
8
+      string: ''
9
+    headers:
10
+      User-Agent:
11
+      - |-
12
+        Huginn/0.0.1 google-api-ruby-client/0.7.1 Linux/3.13.0-29-generic
13
+         (gzip)
14
+      Accept-Encoding:
15
+      - gzip
16
+      Content-Type:
17
+      - ''
18
+      Accept:
19
+      - "*/*"
20
+  response:
21
+    status:
22
+      code: 200
23
+      message: OK
24
+    headers:
25
+      Expires:
26
+      - Sat, 28 Jun 2014 17:21:12 GMT
27
+      Date:
28
+      - Sat, 28 Jun 2014 17:16:12 GMT
29
+      Etag:
30
+      - '"C11OM5Qtr9122-scy_WeqND9D3o/icy_kevyvyjgCKjN6s1gb_9TUZs"'
31
+      Content-Type:
32
+      - application/json; charset=UTF-8
33
+      Content-Encoding:
34
+      - gzip
35
+      X-Content-Type-Options:
36
+      - nosniff
37
+      X-Frame-Options:
38
+      - SAMEORIGIN
39
+      X-Xss-Protection:
40
+      - 1; mode=block
41
+      Content-Length:
42
+      - '11266'
43
+      Server:
44
+      - GSE
45
+      Age:
46
+      - '195'
47
+      Cache-Control:
48
+      - public, max-age=300, must-revalidate, no-transform
49
+      Alternate-Protocol:
50
+      - 443:quic
51
+    body:
52
+      encoding: ASCII-8BIT
53
+      string: !binary |-
54
+        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
304
+        4Rn8/2/P/j8y14+toGwBAA==
305
+    http_version: 
306
+  recorded_at: Sat, 28 Jun 2014 17:19:27 GMT
307
+- request:
308
+    method: post
309
+    uri: https://accounts.google.com/o/oauth2/token
310
+    body:
311
+      encoding: ASCII-8BIT
312
+      string: grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiIxMDI5OTM2OTY2MzI2LW5jamQ3Nzc2cGNzcGM5OGhzZzgyZ3NiNTZ0MzIxN2VmQGRldmVsb3Blci5nc2VydmljZWFjY291bnQuY29tIiwic2NvcGUiOiJodHRwczovL3d3dy5nb29nbGVhcGlzLmNvbS9hdXRoL2NhbGVuZGFyIiwiYXVkIjoiaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tL28vb2F1dGgyL3Rva2VuIiwiZXhwIjoxNDAzOTc2MDI3LCJpYXQiOjE0MDM5NzU5MDd9.G2t_IqQofzzXKJAySGu4MulAybOp3BjptAk_tra7-ALy2pWu0jKw8XblP4T0YPgyMcbhcSJ_OhYl7Inmkxc3xhWsN-ZQDtacIyLv9roIRbvm5zCFKceJRISu2nZuIUTGTeoVuotPh6KRRvXIk1RW_Rc7L0eLeGTWn1USqdNwlfU
313
+    headers:
314
+      Cache-Control:
315
+      - no-store
316
+      Content-Type:
317
+      - application/x-www-form-urlencoded
318
+      Accept-Encoding:
319
+      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
320
+      Accept:
321
+      - "*/*"
322
+      User-Agent:
323
+      - Ruby
324
+  response:
325
+    status:
326
+      code: 200
327
+      message: OK
328
+    headers:
329
+      Content-Type:
330
+      - application/json; charset=utf-8
331
+      Cache-Control:
332
+      - no-cache, no-store, max-age=0, must-revalidate
333
+      Pragma:
334
+      - no-cache
335
+      Expires:
336
+      - Fri, 01 Jan 1990 00:00:00 GMT
337
+      Date:
338
+      - Sat, 28 Jun 2014 17:19:28 GMT
339
+      Content-Disposition:
340
+      - attachment; filename="json.txt"; filename*=UTF-8''json.txt
341
+      X-Content-Type-Options:
342
+      - nosniff
343
+      X-Frame-Options:
344
+      - SAMEORIGIN
345
+      X-Xss-Protection:
346
+      - 1; mode=block
347
+      Server:
348
+      - GSE
349
+      Alternate-Protocol:
350
+      - 443:quic
351
+      Transfer-Encoding:
352
+      - chunked
353
+    body:
354
+      encoding: UTF-8
355
+      string: |-
356
+        {
357
+          "access_token" : "ya29.MgBhhNLHjzGOZBoAAADoyvaDO5G6GHmaxIYqa5EGZ5t8kL-unXKywIRYoYgQxw",
358
+          "token_type" : "Bearer",
359
+          "expires_in" : 3600
360
+        }
361
+    http_version: 
362
+  recorded_at: Sat, 28 Jun 2014 17:19:28 GMT
363
+- request:
364
+    method: post
365
+    uri: https://www.googleapis.com/calendar/v3/calendars/sqv39gj35tc837gdns1g4d81cg@group.calendar.google.com/events?sendNotifications=true
366
+    body:
367
+      encoding: UTF-8
368
+      string: '{"visibility":"default","summary":"Awesome event","description":"An
369
+        example event with text. Pro tip: DateTimes are in RFC3339","end":{"dateTime":"2014-10-02T11:00:00-05:00"},"start":{"dateTime":"2014-10-02T10:00:00-05:00"}}'
370
+    headers:
371
+      User-Agent:
372
+      - |-
373
+        Huginn/0.0.1 google-api-ruby-client/0.7.1 Linux/3.13.0-29-generic
374
+         (gzip)
375
+      Content-Type:
376
+      - application/json
377
+      Accept-Encoding:
378
+      - gzip
379
+      Authorization:
380
+      - Bearer ya29.MgBhhNLHjzGOZBoAAADoyvaDO5G6GHmaxIYqa5EGZ5t8kL-unXKywIRYoYgQxw
381
+      Cache-Control:
382
+      - no-store
383
+      Accept:
384
+      - "*/*"
385
+  response:
386
+    status:
387
+      code: 200
388
+      message: OK
389
+    headers:
390
+      Cache-Control:
391
+      - no-cache, no-store, max-age=0, must-revalidate
392
+      Pragma:
393
+      - no-cache
394
+      Expires:
395
+      - Fri, 01 Jan 1990 00:00:00 GMT
396
+      Date:
397
+      - Sat, 28 Jun 2014 17:19:31 GMT
398
+      Etag:
399
+      - '"BZUCgRsJHN1b3Y4VmmLXiJzEzGI/MjgwNzk1MTk0MTk3MjAwMA"'
400
+      Content-Type:
401
+      - application/json; charset=UTF-8
402
+      Content-Encoding:
403
+      - gzip
404
+      X-Content-Type-Options:
405
+      - nosniff
406
+      X-Frame-Options:
407
+      - SAMEORIGIN
408
+      X-Xss-Protection:
409
+      - 1; mode=block
410
+      Server:
411
+      - GSE
412
+      Alternate-Protocol:
413
+      - 443:quic
414
+      Transfer-Encoding:
415
+      - chunked
416
+    body:
417
+      encoding: ASCII-8BIT
418
+      string: !binary |-
419
+        H4sIAAAAAAAAAH1T0U7bMBR971dY3ePW1IlLmlSaRkdXRkWqCZUxKl6Mfeu6
420
+        iZ1gOw0E8e9LAh2btvFgKc49Pufce+zHHuqnUvP+BPUZzUBzat7BHrTrf2hK
421
+        4KhoSzf9z+vLE3FhF1+X/i25Hn1X6vyHXNRf6tOzYbIT1bJO/WSV4maRZDet
422
+        kulNv6OQHbfd850RocF7mpa3dp9FjJfW5zLtQNZRV9rORK430ijg3f+tU9m5
423
+        1Glb2TpX2MlwWFWVJ/JcZOCxXA0Proed608g+UdG1inVC73UZxVXc8OvFjVX
424
+        1eg6uPBZPU/plRWMzIOkzjTdJT4/TUZJzfX69KhOrhhez8T9dcCn66ozwQxQ
425
+        B10bAfZHAxwOgmjljyd+PAliD2O87nBlwf+LI9iLo/AZZ0ulqHlocdMKbK4A
426
+        vU6cg2VGFk7mugNoBPdUFdkLBlXSbZGDe+ehbyZHThYTNGtkV1KBRdQAkhpd
427
+        zE8IIfGr+9w0ZI891CSqqMxaZh8HcUzCOAxJEA402/HxeBwWzBYsjrZWRIGw
428
+        t0ehI4E/hs0xb+SzvADjCQtmLxlQxvJSuzaEfg89tVq5EVTLGv5Ws3d7Eosd
429
+        OXIsImPBtfXFiEc+E8fC5GXhHWL8LdrWfjMQaYuMPiypgpboaymk1ug8Z7Sd
430
+        UfMhnnEWsk0DcKaEFzfNpTLu4IS/zOhXOj4eYLLCuIlmgvF73GZ06AO6B/Hm
431
+        Of9f5+QJzS7PZm9f+OM/O2yM35WgWauA270B1TxIMPZgobQwgw0tM/faX++p
432
+        9xPxx2cruwMAAA==
433
+    http_version: 
434
+  recorded_at: Sat, 28 Jun 2014 17:19:31 GMT
435
+recorded_with: VCR 2.9.2

+ 356 - 0
spec/data_fixtures/github_rss.atom

@@ -0,0 +1,356 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xml:lang="en-US">
3
+  <id>tag:github.com,2008:/cantino/huginn/commits/master</id>
4
+  <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commits/master"/>
5
+  <link type="application/atom+xml" rel="self" href="https://github.com/cantino/huginn/commits/master.atom"/>
6
+  <title>Recent Commits to huginn:master</title>
7
+  <updated>2014-07-16T22:26:22-07:00</updated>
8
+  <entry>
9
+    <id>tag:github.com,2008:Grit::Commit/d0a844662846cf3c83b94c637c1803f03db5a5b0</id>
10
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/d0a844662846cf3c83b94c637c1803f03db5a5b0"/>
11
+    <title>
12
+        Merge pull request #402 from albertsun/safer-liquid-migration
13
+    </title>
14
+    <updated>2014-07-16T22:26:22-07:00</updated>
15
+    <media:thumbnail height="30" width="30" url="https://avatars3.githubusercontent.com/u/83835?s=30"/>
16
+    <author>
17
+      <name>cantino</name>
18
+      <uri>https://github.com/cantino</uri>
19
+    </author>
20
+    <content type="html">
21
+      &lt;pre style='white-space:pre-wrap;width:81ex'>Merge pull request #402 from albertsun/safer-liquid-migration
22
+
23
+Inline models into migration&lt;/pre>
24
+    </content>
25
+  </entry>
26
+  <entry>
27
+    <id>tag:github.com,2008:Grit::Commit/4a433806eeace44f1e39f02ac61cefdadf3597e2</id>
28
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/4a433806eeace44f1e39f02ac61cefdadf3597e2"/>
29
+    <title>
30
+        inline models into migration
31
+    </title>
32
+    <updated>2014-07-16T15:25:08-04:00</updated>
33
+    <media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/382862?s=30"/>
34
+    <author>
35
+      <name>albertsun</name>
36
+      <uri>https://github.com/albertsun</uri>
37
+    </author>
38
+    <content type="html">
39
+      &lt;pre style='white-space:pre-wrap;width:81ex'>inline models into migration&lt;/pre>
40
+    </content>
41
+  </entry>
42
+  <entry>
43
+    <id>tag:github.com,2008:Grit::Commit/6ffa528ab0af7f9f5bb4b68437e7613e74fdb8c4</id>
44
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/6ffa528ab0af7f9f5bb4b68437e7613e74fdb8c4"/>
45
+    <title>
46
+        Merge pull request #398 from knu/imap_use_uid
47
+    </title>
48
+    <updated>2014-07-15T19:47:37-07:00</updated>
49
+    <media:thumbnail height="30" width="30" url="https://avatars3.githubusercontent.com/u/83835?s=30"/>
50
+    <author>
51
+      <name>cantino</name>
52
+      <uri>https://github.com/cantino</uri>
53
+    </author>
54
+    <content type="html">
55
+      &lt;pre style='white-space:pre-wrap;width:81ex'>Merge pull request #398 from knu/imap_use_uid
56
+
57
+Use &quot;last seen UID&quot; in ImapFolderAgent&lt;/pre>
58
+    </content>
59
+  </entry>
60
+  <entry>
61
+    <id>tag:github.com,2008:Grit::Commit/c7e29492c98652cc9738c374d02dcbb7c9bdeac6</id>
62
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/c7e29492c98652cc9738c374d02dcbb7c9bdeac6"/>
63
+    <title>
64
+        Merge pull request #391 from theofpa/master
65
+    </title>
66
+    <updated>2014-07-12T15:19:56-07:00</updated>
67
+    <media:thumbnail height="30" width="30" url="https://avatars3.githubusercontent.com/u/83835?s=30"/>
68
+    <author>
69
+      <name>cantino</name>
70
+      <uri>https://github.com/cantino</uri>
71
+    </author>
72
+    <content type="html">
73
+      &lt;pre style='white-space:pre-wrap;width:81ex'>Merge pull request #391 from theofpa/master
74
+
75
+Ignore xmlns when evaluating xpath&lt;/pre>
76
+    </content>
77
+  </entry>
78
+  <entry>
79
+    <id>tag:github.com,2008:Grit::Commit/f3552ece2e9af187bd5e613783dd27810b63c32f</id>
80
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/f3552ece2e9af187bd5e613783dd27810b63c32f"/>
81
+    <title>
82
+        ImapFolderAgent: Emit a log message when creating an event or skipping it.
83
+    </title>
84
+    <updated>2014-07-11T19:19:12+09:00</updated>
85
+    <media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/10236?s=30"/>
86
+    <author>
87
+      <name>knu</name>
88
+      <uri>https://github.com/knu</uri>
89
+    </author>
90
+    <content type="html">
91
+      &lt;pre style='white-space:pre-wrap;width:81ex'>ImapFolderAgent: Emit a log message when creating an event or skipping it.&lt;/pre>
92
+    </content>
93
+  </entry>
94
+  <entry>
95
+    <id>tag:github.com,2008:Grit::Commit/d144d3797d2db362943357c6d85238ec657cfa06</id>
96
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/d144d3797d2db362943357c6d85238ec657cfa06"/>
97
+    <title>
98
+        ImapFolderAgent: Enable notification of mails already marked as read.
99
+    </title>
100
+    <updated>2014-07-11T19:08:55+09:00</updated>
101
+    <media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/10236?s=30"/>
102
+    <author>
103
+      <name>knu</name>
104
+      <uri>https://github.com/knu</uri>
105
+    </author>
106
+    <content type="html">
107
+      &lt;pre style='white-space:pre-wrap;width:81ex'>ImapFolderAgent: Enable notification of mails already marked as read.
108
+
109
+Add a condition key &quot;is_unread&quot; to allow user to select mails based on
110
+the read status.&lt;/pre>
111
+    </content>
112
+  </entry>
113
+  <entry>
114
+    <id>tag:github.com,2008:Grit::Commit/d1196a35ada22418bf0cf8b0d5947c2164e983e6</id>
115
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/d1196a35ada22418bf0cf8b0d5947c2164e983e6"/>
116
+    <title>
117
+        ImapFolderAgent: &quot;conditions&quot; must not actually be nil.
118
+    </title>
119
+    <updated>2014-07-11T18:02:09+09:00</updated>
120
+    <media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/10236?s=30"/>
121
+    <author>
122
+      <name>knu</name>
123
+      <uri>https://github.com/knu</uri>
124
+    </author>
125
+    <content type="html">
126
+      &lt;pre style='white-space:pre-wrap;width:81ex'>ImapFolderAgent: &quot;conditions&quot; must not actually be nil.&lt;/pre>
127
+    </content>
128
+  </entry>
129
+  <entry>
130
+    <id>tag:github.com,2008:Grit::Commit/280c09415ea8114d8a128cd7c2583ae0e0aa480d</id>
131
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/280c09415ea8114d8a128cd7c2583ae0e0aa480d"/>
132
+    <title>
133
+        ImapFolderAgent: Do not fail when port is blank.
134
+    </title>
135
+    <updated>2014-07-11T18:02:09+09:00</updated>
136
+    <media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/10236?s=30"/>
137
+    <author>
138
+      <name>knu</name>
139
+      <uri>https://github.com/knu</uri>
140
+    </author>
141
+    <content type="html">
142
+      &lt;pre style='white-space:pre-wrap;width:81ex'>ImapFolderAgent: Do not fail when port is blank.&lt;/pre>
143
+    </content>
144
+  </entry>
145
+  <entry>
146
+    <id>tag:github.com,2008:Grit::Commit/045fb957b2370d80190fa8dc036863076d8806fb</id>
147
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/045fb957b2370d80190fa8dc036863076d8806fb"/>
148
+    <title>
149
+        ImapFolderAgent now recognizes &quot;true&quot;/&quot;false&quot; as boolean option values.
150
+    </title>
151
+    <updated>2014-07-11T18:02:09+09:00</updated>
152
+    <media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/10236?s=30"/>
153
+    <author>
154
+      <name>knu</name>
155
+      <uri>https://github.com/knu</uri>
156
+    </author>
157
+    <content type="html">
158
+      &lt;pre style='white-space:pre-wrap;width:81ex'>ImapFolderAgent now recognizes &quot;true&quot;/&quot;false&quot; as boolean option values.
159
+
160
+Add a utility method Agent#boolify to make it easier to handle boolean
161
+option values.&lt;/pre>
162
+    </content>
163
+  </entry>
164
+  <entry>
165
+    <id>tag:github.com,2008:Grit::Commit/c1b9caa8ccb0c8b8f6103fc80b90fba57a822435</id>
166
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/c1b9caa8ccb0c8b8f6103fc80b90fba57a822435"/>
167
+    <title>
168
+        ImapFolderAgent: Unstringify integer keys of a hash saved in JSON.
169
+    </title>
170
+    <updated>2014-07-11T18:01:26+09:00</updated>
171
+    <media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/10236?s=30"/>
172
+    <author>
173
+      <name>knu</name>
174
+      <uri>https://github.com/knu</uri>
175
+    </author>
176
+    <content type="html">
177
+      &lt;pre style='white-space:pre-wrap;width:81ex'>ImapFolderAgent: Unstringify integer keys of a hash saved in JSON.&lt;/pre>
178
+    </content>
179
+  </entry>
180
+  <entry>
181
+    <id>tag:github.com,2008:Grit::Commit/6a06a32447721abc4477979610e36db0650e2f92</id>
182
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/6a06a32447721abc4477979610e36db0650e2f92"/>
183
+    <title>
184
+        ImapFolderAgent: Only keep a single UID value for each folder in memory.
185
+    </title>
186
+    <updated>2014-07-11T18:01:26+09:00</updated>
187
+    <media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/10236?s=30"/>
188
+    <author>
189
+      <name>knu</name>
190
+      <uri>https://github.com/knu</uri>
191
+    </author>
192
+    <content type="html">
193
+      &lt;pre style='white-space:pre-wrap;width:81ex'>ImapFolderAgent: Only keep a single UID value for each folder in memory.
194
+
195
+Previously it used to keep a list of the UIDs of unread mails.  Now we
196
+start to assume that UIDs in a folder identified by a UID VALIDITY value
197
+are strictly ascending (monotonically increasing) as suggested by RFC
198
+3501 and 4549 and just keep the highest UID seen in the last run.
199
+
200
+This enhancement will help reduce the size of memory typically where
201
+mails are left unread forever.&lt;/pre>
202
+    </content>
203
+  </entry>
204
+  <entry>
205
+    <id>tag:github.com,2008:Grit::Commit/9ed63e45b247c30a02e8e59b4d24fccbe8644876</id>
206
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/9ed63e45b247c30a02e8e59b4d24fccbe8644876"/>
207
+    <title>
208
+        Merge pull request #397 from cantino/update_rails_and_gems
209
+    </title>
210
+    <updated>2014-07-05T16:34:29-07:00</updated>
211
+    <media:thumbnail height="30" width="30" url="https://avatars3.githubusercontent.com/u/83835?s=30"/>
212
+    <author>
213
+      <name>cantino</name>
214
+      <uri>https://github.com/cantino</uri>
215
+    </author>
216
+    <content type="html">
217
+      &lt;pre style='white-space:pre-wrap;width:81ex'>Merge pull request #397 from cantino/update_rails_and_gems
218
+
219
+upgrade rails and gems&lt;/pre>
220
+    </content>
221
+  </entry>
222
+  <entry>
223
+    <id>tag:github.com,2008:Grit::Commit/87a7abda23a82305d7050ac0bb400ce36c863d01</id>
224
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/87a7abda23a82305d7050ac0bb400ce36c863d01"/>
225
+    <title>
226
+        upgrade rails and gems
227
+    </title>
228
+    <updated>2014-07-05T08:01:36-07:00</updated>
229
+    <media:thumbnail height="30" width="30" url="https://avatars3.githubusercontent.com/u/83835?s=30"/>
230
+    <author>
231
+      <name>cantino</name>
232
+      <uri>https://github.com/cantino</uri>
233
+    </author>
234
+    <content type="html">
235
+      &lt;pre style='white-space:pre-wrap;width:81ex'>upgrade rails and gems&lt;/pre>
236
+    </content>
237
+  </entry>
238
+  <entry>
239
+    <id>tag:github.com,2008:Grit::Commit/ea7594fa976fe24bb7024b6e3e0d2881dd86033a</id>
240
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/ea7594fa976fe24bb7024b6e3e0d2881dd86033a"/>
241
+    <title>
242
+        Merge pull request #396 from knu/show_propagate_immediately
243
+    </title>
244
+    <updated>2014-07-03T20:50:40-07:00</updated>
245
+    <media:thumbnail height="30" width="30" url="https://avatars3.githubusercontent.com/u/83835?s=30"/>
246
+    <author>
247
+      <name>cantino</name>
248
+      <uri>https://github.com/cantino</uri>
249
+    </author>
250
+    <content type="html">
251
+      &lt;pre style='white-space:pre-wrap;width:81ex'>Merge pull request #396 from knu/show_propagate_immediately
252
+
253
+Make propagate_immediately more visible in agent details and the diagram.&lt;/pre>
254
+    </content>
255
+  </entry>
256
+  <entry>
257
+    <id>tag:github.com,2008:Grit::Commit/0e80f5341587aace2c023b06eb9265b776ac4535</id>
258
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/0e80f5341587aace2c023b06eb9265b776ac4535"/>
259
+    <title>
260
+        Dashed line in a diagram indicates propagate_immediately being false.
261
+    </title>
262
+    <updated>2014-07-04T03:42:52+09:00</updated>
263
+    <media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/10236?s=30"/>
264
+    <author>
265
+      <name>knu</name>
266
+      <uri>https://github.com/knu</uri>
267
+    </author>
268
+    <content type="html">
269
+      &lt;pre style='white-space:pre-wrap;width:81ex'>Dashed line in a diagram indicates propagate_immediately being false.&lt;/pre>
270
+    </content>
271
+  </entry>
272
+  <entry>
273
+    <id>tag:github.com,2008:Grit::Commit/cf9cdfb3ac9d47b7fdf5d7669577c964bee9a186</id>
274
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/cf9cdfb3ac9d47b7fdf5d7669577c964bee9a186"/>
275
+    <title>
276
+        Show the propagate_immediately flag in agent details.
277
+    </title>
278
+    <updated>2014-07-04T02:53:31+09:00</updated>
279
+    <media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/10236?s=30"/>
280
+    <author>
281
+      <name>knu</name>
282
+      <uri>https://github.com/knu</uri>
283
+    </author>
284
+    <content type="html">
285
+      &lt;pre style='white-space:pre-wrap;width:81ex'>Show the propagate_immediately flag in agent details.&lt;/pre>
286
+    </content>
287
+  </entry>
288
+  <entry>
289
+    <id>tag:github.com,2008:Grit::Commit/b1128335b8de98afc5cad1b2ca5573e3bab1da1d</id>
290
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/b1128335b8de98afc5cad1b2ca5573e3bab1da1d"/>
291
+    <title>
292
+        Merge pull request #389 from dsander/silence_worker_status
293
+    </title>
294
+    <updated>2014-07-01T21:47:40-07:00</updated>
295
+    <media:thumbnail height="30" width="30" url="https://avatars3.githubusercontent.com/u/83835?s=30"/>
296
+    <author>
297
+      <name>cantino</name>
298
+      <uri>https://github.com/cantino</uri>
299
+    </author>
300
+    <content type="html">
301
+      &lt;pre style='white-space:pre-wrap;width:81ex'>Merge pull request #389 from dsander/silence_worker_status
302
+
303
+Supress logging for requests to the /worker_status&lt;/pre>
304
+    </content>
305
+  </entry>
306
+  <entry>
307
+    <id>tag:github.com,2008:Grit::Commit/d25e670b1c040f78eb648120c117853421d522c3</id>
308
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/d25e670b1c040f78eb648120c117853421d522c3"/>
309
+    <title>
310
+        Merge pull request #393 from CloCkWeRX/google_calendar
311
+    </title>
312
+    <updated>2014-07-01T21:47:16-07:00</updated>
313
+    <media:thumbnail height="30" width="30" url="https://avatars3.githubusercontent.com/u/83835?s=30"/>
314
+    <author>
315
+      <name>cantino</name>
316
+      <uri>https://github.com/cantino</uri>
317
+    </author>
318
+    <content type="html">
319
+      &lt;pre style='white-space:pre-wrap;width:81ex'>Merge pull request #393 from CloCkWeRX/google_calendar
320
+
321
+Add Google calendar publish agent&lt;/pre>
322
+    </content>
323
+  </entry>
324
+  <entry>
325
+    <id>tag:github.com,2008:Grit::Commit/d7b0e35aaaafec3032d3fe271b426f1e9d3727b4</id>
326
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/d7b0e35aaaafec3032d3fe271b426f1e9d3727b4"/>
327
+    <title>
328
+        switch to cantino-twitter-stream
329
+    </title>
330
+    <updated>2014-07-01T21:36:38-07:00</updated>
331
+    <media:thumbnail height="30" width="30" url="https://avatars3.githubusercontent.com/u/83835?s=30"/>
332
+    <author>
333
+      <name>cantino</name>
334
+      <uri>https://github.com/cantino</uri>
335
+    </author>
336
+    <content type="html">
337
+      &lt;pre style='white-space:pre-wrap;width:81ex'>switch to cantino-twitter-stream&lt;/pre>
338
+    </content>
339
+  </entry>
340
+  <entry>
341
+    <id>tag:github.com,2008:Grit::Commit/d465158f77dcd9078697e6167b50abbfdfa8b1af</id>
342
+    <link type="text/html" rel="alternate" href="https://github.com/cantino/huginn/commit/d465158f77dcd9078697e6167b50abbfdfa8b1af"/>
343
+    <title>
344
+        Shift to dev group
345
+    </title>
346
+    <updated>2014-07-01T16:37:47+09:30</updated>
347
+    <media:thumbnail height="30" width="30" url="https://avatars3.githubusercontent.com/u/365751?s=30"/>
348
+    <author>
349
+      <name>CloCkWeRX</name>
350
+      <uri>https://github.com/CloCkWeRX</uri>
351
+    </author>
352
+    <content type="html">
353
+      &lt;pre style='white-space:pre-wrap;width:81ex'>Shift to dev group&lt;/pre>
354
+    </content>
355
+  </entry>
356
+</feed>

+ 2601 - 0
spec/data_fixtures/google_calendar_api.json

@@ -0,0 +1,2601 @@
1
+{
2
+ "kind": "discovery#restDescription",
3
+ "etag": "\"C11OM5Qtr9122-scy_WeqND9D3o/icy_kevyvyjgCKjN6s1gb_9TUZs\"",
4
+ "discoveryVersion": "v1",
5
+ "id": "calendar:v3",
6
+ "name": "calendar",
7
+ "version": "v3",
8
+ "title": "Calendar API",
9
+ "description": "Lets you manipulate events and other calendar data.",
10
+ "ownerDomain": "google.com",
11
+ "ownerName": "Google",
12
+ "icons": {
13
+  "x16": "http://www.google.com/images/icons/product/calendar-16.png",
14
+  "x32": "http://www.google.com/images/icons/product/calendar-32.png"
15
+ },
16
+ "documentationLink": "https://developers.google.com/google-apps/calendar/firstapp",
17
+ "protocol": "rest",
18
+ "baseUrl": "https://www.googleapis.com/calendar/v3/",
19
+ "basePath": "/calendar/v3/",
20
+ "rootUrl": "https://www.googleapis.com/",
21
+ "servicePath": "calendar/v3/",
22
+ "batchPath": "batch",
23
+ "parameters": {
24
+  "alt": {
25
+   "type": "string",
26
+   "description": "Data format for the response.",
27
+   "default": "json",
28
+   "enum": [
29
+    "json"
30
+   ],
31
+   "enumDescriptions": [
32
+    "Responses with Content-Type of application/json"
33
+   ],
34
+   "location": "query"
35
+  },
36
+  "fields": {
37
+   "type": "string",
38
+   "description": "Selector specifying which fields to include in a partial response.",
39
+   "location": "query"
40
+  },
41
+  "key": {
42
+   "type": "string",
43
+   "description": "API key. Your API key identifies your project and provides you with API access, quota, and reports. Required unless you provide an OAuth 2.0 token.",
44
+   "location": "query"
45
+  },
46
+  "oauth_token": {
47
+   "type": "string",
48
+   "description": "OAuth 2.0 token for the current user.",
49
+   "location": "query"
50
+  },
51
+  "prettyPrint": {
52
+   "type": "boolean",
53
+   "description": "Returns response with indentations and line breaks.",
54
+   "default": "true",
55
+   "location": "query"
56
+  },
57
+  "quotaUser": {
58
+   "type": "string",
59
+   "description": "Available to use for quota purposes for server-side applications. Can be any arbitrary string assigned to a user, but should not exceed 40 characters. Overrides userIp if both are provided.",
60
+   "location": "query"
61
+  },
62
+  "userIp": {
63
+   "type": "string",
64
+   "description": "IP address of the site where the request originates. Use this if you want to enforce per-user limits.",
65
+   "location": "query"
66
+  }
67
+ },
68
+ "auth": {
69
+  "oauth2": {
70
+   "scopes": {
71
+    "https://www.googleapis.com/auth/calendar": {
72
+     "description": "Manage your calendars"
73
+    },
74
+    "https://www.googleapis.com/auth/calendar.readonly": {
75
+     "description": "View your calendars"
76
+    }
77
+   }
78
+  }
79
+ },
80
+ "schemas": {
81
+  "Acl": {
82
+   "id": "Acl",
83
+   "type": "object",
84
+   "properties": {
85
+    "etag": {
86
+     "type": "string",
87
+     "description": "ETag of the collection."
88
+    },
89
+    "items": {
90
+     "type": "array",
91
+     "description": "List of rules on the access control list.",
92
+     "items": {
93
+      "$ref": "AclRule"
94
+     }
95
+    },
96
+    "kind": {
97
+     "type": "string",
98
+     "description": "Type of the collection (\"calendar#acl\").",
99
+     "default": "calendar#acl"
100
+    },
101
+    "nextPageToken": {
102
+     "type": "string",
103
+     "description": "Token used to access the next page of this result. Omitted if no further results are available, in which case nextSyncToken is provided."
104
+    },
105
+    "nextSyncToken": {
106
+     "type": "string",
107
+     "description": "Token used at a later point in time to retrieve only the entries that have changed since this result was returned. Omitted if further results are available, in which case nextPageToken is provided."
108
+    }
109
+   }
110
+  },
111
+  "AclRule": {
112
+   "id": "AclRule",
113
+   "type": "object",
114
+   "properties": {
115
+    "etag": {
116
+     "type": "string",
117
+     "description": "ETag of the resource."
118
+    },
119
+    "id": {
120
+     "type": "string",
121
+     "description": "Identifier of the ACL rule."
122
+    },
123
+    "kind": {
124
+     "type": "string",
125
+     "description": "Type of the resource (\"calendar#aclRule\").",
126
+     "default": "calendar#aclRule"
127
+    },
128
+    "role": {
129
+     "type": "string",
130
+     "description": "The role assigned to the scope. Possible values are:  \n- \"none\" - Provides no access. \n- \"freeBusyReader\" - Provides read access to free/busy information. \n- \"reader\" - Provides read access to the calendar. Private events will appear to users with reader access, but event details will be hidden. \n- \"writer\" - Provides read and write access to the calendar. Private events will appear to users with writer access, and event details will be visible. \n- \"owner\" - Provides ownership of the calendar. This role has all of the permissions of the writer role with the additional ability to see and manipulate ACLs.",
131
+     "annotations": {
132
+      "required": [
133
+       "calendar.acl.insert"
134
+      ]
135
+     }
136
+    },
137
+    "scope": {
138
+     "type": "object",
139
+     "description": "The scope of the rule.",
140
+     "properties": {
141
+      "type": {
142
+       "type": "string",
143
+       "description": "The type of the scope. Possible values are:  \n- \"default\" - The public scope. This is the default value. \n- \"user\" - Limits the scope to a single user. \n- \"group\" - Limits the scope to a group. \n- \"domain\" - Limits the scope to a domain.  Note: The permissions granted to the \"default\", or public, scope apply to any user, authenticated or not.",
144
+       "annotations": {
145
+        "required": [
146
+         "calendar.acl.insert"
147
+        ]
148
+       }
149
+      },
150
+      "value": {
151
+       "type": "string",
152
+       "description": "The email address of a user or group, or the name of a domain, depending on the scope type. Omitted for type \"default\"."
153
+      }
154
+     },
155
+     "annotations": {
156
+      "required": [
157
+       "calendar.acl.insert"
158
+      ]
159
+     }
160
+    }
161
+   }
162
+  },
163
+  "Calendar": {
164
+   "id": "Calendar",
165
+   "type": "object",
166
+   "properties": {
167
+    "description": {
168
+     "type": "string",
169
+     "description": "Description of the calendar. Optional."
170
+    },
171
+    "etag": {
172
+     "type": "string",
173
+     "description": "ETag of the resource."
174
+    },
175
+    "id": {
176
+     "type": "string",
177
+     "description": "Identifier of the calendar."
178
+    },
179
+    "kind": {
180
+     "type": "string",
181
+     "description": "Type of the resource (\"calendar#calendar\").",
182
+     "default": "calendar#calendar"
183
+    },
184
+    "location": {
185
+     "type": "string",
186
+     "description": "Geographic location of the calendar as free-form text. Optional."
187
+    },
188
+    "summary": {
189
+     "type": "string",
190
+     "description": "Title of the calendar.",
191
+     "annotations": {
192
+      "required": [
193
+       "calendar.calendars.insert"
194
+      ]
195
+     }
196
+    },
197
+    "timeZone": {
198
+     "type": "string",
199
+     "description": "The time zone of the calendar. Optional."
200
+    }
201
+   }
202
+  },
203
+  "CalendarList": {
204
+   "id": "CalendarList",
205
+   "type": "object",
206
+   "properties": {
207
+    "etag": {
208
+     "type": "string",
209
+     "description": "ETag of the collection."
210
+    },
211
+    "items": {
212
+     "type": "array",
213
+     "description": "Calendars that are present on the user's calendar list.",
214
+     "items": {
215
+      "$ref": "CalendarListEntry"
216
+     }
217
+    },
218
+    "kind": {
219
+     "type": "string",
220
+     "description": "Type of the collection (\"calendar#calendarList\").",
221
+     "default": "calendar#calendarList"
222
+    },
223
+    "nextPageToken": {
224
+     "type": "string",
225
+     "description": "Token used to access the next page of this result. Omitted if no further results are available, in which case nextSyncToken is provided."
226
+    },
227
+    "nextSyncToken": {
228
+     "type": "string",
229
+     "description": "Token used at a later point in time to retrieve only the entries that have changed since this result was returned. Omitted if further results are available, in which case nextPageToken is provided."
230
+    }
231
+   }
232
+  },
233
+  "CalendarListEntry": {
234
+   "id": "CalendarListEntry",
235
+   "type": "object",
236
+   "properties": {
237
+    "accessRole": {
238
+     "type": "string",
239
+     "description": "The effective access role that the authenticated user has on the calendar. Read-only. Possible values are:  \n- \"freeBusyReader\" - Provides read access to free/busy information. \n- \"reader\" - Provides read access to the calendar. Private events will appear to users with reader access, but event details will be hidden. \n- \"writer\" - Provides read and write access to the calendar. Private events will appear to users with writer access, and event details will be visible. \n- \"owner\" - Provides ownership of the calendar. This role has all of the permissions of the writer role with the additional ability to see and manipulate ACLs."
240
+    },
241
+    "backgroundColor": {
242
+     "type": "string",
243
+     "description": "The main color of the calendar in the hexadecimal format \"#0088aa\". This property supersedes the index-based colorId property. Optional."
244
+    },
245
+    "colorId": {
246
+     "type": "string",
247
+     "description": "The color of the calendar. This is an ID referring to an entry in the calendar section of the colors definition (see the colors endpoint). Optional."
248
+    },
249
+    "defaultReminders": {
250
+     "type": "array",
251
+     "description": "The default reminders that the authenticated user has for this calendar.",
252
+     "items": {
253
+      "$ref": "EventReminder"
254
+     }
255
+    },
256
+    "deleted": {
257
+     "type": "boolean",
258
+     "description": "Whether this calendar list entry has been deleted from the calendar list. Read-only. Optional. The default is False.",
259
+     "default": "false"
260
+    },
261
+    "description": {
262
+     "type": "string",
263
+     "description": "Description of the calendar. Optional. Read-only."
264
+    },
265
+    "etag": {
266
+     "type": "string",
267
+     "description": "ETag of the resource."
268
+    },
269
+    "foregroundColor": {
270
+     "type": "string",
271
+     "description": "The foreground color of the calendar in the hexadecimal format \"#ffffff\". This property supersedes the index-based colorId property. Optional."
272
+    },
273
+    "hidden": {
274
+     "type": "boolean",
275
+     "description": "Whether the calendar has been hidden from the list. Optional. The default is False.",
276
+     "default": "false"
277
+    },
278
+    "id": {
279
+     "type": "string",
280
+     "description": "Identifier of the calendar.",
281
+     "annotations": {
282
+      "required": [
283
+       "calendar.calendarList.insert"
284
+      ]
285
+     }
286
+    },
287
+    "kind": {
288
+     "type": "string",
289
+     "description": "Type of the resource (\"calendar#calendarListEntry\").",
290
+     "default": "calendar#calendarListEntry"
291
+    },
292
+    "location": {
293
+     "type": "string",
294
+     "description": "Geographic location of the calendar as free-form text. Optional. Read-only."
295
+    },
296
+    "notificationSettings": {
297
+     "type": "object",
298
+     "description": "The notifications that the authenticated user is receiving for this calendar.",
299
+     "properties": {
300
+      "notifications": {
301
+       "type": "array",
302
+       "description": "The list of notifications set for this calendar.",
303
+       "items": {
304
+        "$ref": "CalendarNotification"
305
+       }
306
+      }
307
+     }
308
+    },
309
+    "primary": {
310
+     "type": "boolean",
311
+     "description": "Whether the calendar is the primary calendar of the authenticated user. Read-only. Optional. The default is False.",
312
+     "default": "false"
313
+    },
314
+    "selected": {
315
+     "type": "boolean",
316
+     "description": "Whether the calendar content shows up in the calendar UI. Optional. The default is False.",
317
+     "default": "false"
318
+    },
319
+    "summary": {
320
+     "type": "string",
321
+     "description": "Title of the calendar. Read-only."
322
+    },
323
+    "summaryOverride": {
324
+     "type": "string",
325
+     "description": "The summary that the authenticated user has set for this calendar. Optional."
326
+    },
327
+    "timeZone": {
328
+     "type": "string",
329
+     "description": "The time zone of the calendar. Optional. Read-only."
330
+    }
331
+   }
332
+  },
333
+  "CalendarNotification": {
334
+   "id": "CalendarNotification",
335
+   "type": "object",
336
+   "properties": {
337
+    "method": {
338
+     "type": "string",
339
+     "description": "The method used to deliver the notification. Possible values are:  \n- \"email\" - Reminders are sent via email. \n- \"sms\" - Reminders are sent via SMS. This value is read-only and is ignored on inserts and updates.",
340
+     "annotations": {
341
+      "required": [
342
+       "calendar.calendarList.insert",
343
+       "calendar.calendarList.update"
344
+      ]
345
+     }
346
+    },
347
+    "type": {
348
+     "type": "string",
349
+     "description": "The type of notification. Possible values are:  \n- \"eventCreation\" - Notification sent when a new event is put on the calendar. \n- \"eventChange\" - Notification sent when an event is changed. \n- \"eventCancellation\" - Notification sent when an event is cancelled. \n- \"eventResponse\" - Notification sent when an event is changed. \n- \"agenda\" - An agenda with the events of the day (sent out in the morning).",
350
+     "annotations": {
351
+      "required": [
352
+       "calendar.calendarList.insert",
353
+       "calendar.calendarList.update"
354
+      ]
355
+     }
356
+    }
357
+   }
358
+  },
359
+  "Channel": {
360
+   "id": "Channel",
361
+   "type": "object",
362
+   "properties": {
363
+    "address": {
364
+     "type": "string",
365
+     "description": "The address where notifications are delivered for this channel."
366
+    },
367
+    "expiration": {
368
+     "type": "string",
369
+     "description": "Date and time of notification channel expiration, expressed as a Unix timestamp, in milliseconds. Optional.",
370
+     "format": "int64"
371
+    },
372
+    "id": {
373
+     "type": "string",
374
+     "description": "A UUID or similar unique string that identifies this channel."
375
+    },
376
+    "kind": {
377
+     "type": "string",
378
+     "description": "Identifies this as a notification channel used to watch for changes to a resource. Value: the fixed string \"api#channel\".",
379
+     "default": "api#channel"
380
+    },
381
+    "params": {
382
+     "type": "object",
383
+     "description": "Additional parameters controlling delivery channel behavior. Optional.",
384
+     "additionalProperties": {
385
+      "type": "string",
386
+      "description": "Declares a new parameter by name."
387
+     }
388
+    },
389
+    "payload": {
390
+     "type": "boolean",
391
+     "description": "A Boolean value to indicate whether payload is wanted. Optional."
392
+    },
393
+    "resourceId": {
394
+     "type": "string",
395
+     "description": "An opaque ID that identifies the resource being watched on this channel. Stable across different API versions."
396
+    },
397
+    "resourceUri": {
398
+     "type": "string",
399
+     "description": "A version-specific identifier for the watched resource."
400
+    },
401
+    "token": {
402
+     "type": "string",
403
+     "description": "An arbitrary string delivered to the target address with each notification delivered over this channel. Optional."
404
+    },
405
+    "type": {
406
+     "type": "string",
407
+     "description": "The type of delivery mechanism used for this channel."
408
+    }
409
+   }
410
+  },
411
+  "ColorDefinition": {
412
+   "id": "ColorDefinition",
413
+   "type": "object",
414
+   "properties": {
415
+    "background": {
416
+     "type": "string",
417
+     "description": "The background color associated with this color definition."
418
+    },
419
+    "foreground": {
420
+     "type": "string",
421
+     "description": "The foreground color that can be used to write on top of a background with 'background' color."
422
+    }
423
+   }
424
+  },
425
+  "Colors": {
426
+   "id": "Colors",
427
+   "type": "object",
428
+   "properties": {
429
+    "calendar": {
430
+     "type": "object",
431
+     "description": "Palette of calendar colors, mapping from the color ID to its definition. A calendarListEntry resource refers to one of these color IDs in its color field. Read-only.",
432
+     "additionalProperties": {
433
+      "$ref": "ColorDefinition",
434
+      "description": "A calendar color defintion."
435
+     }
436
+    },
437
+    "event": {
438
+     "type": "object",
439
+     "description": "Palette of event colors, mapping from the color ID to its definition. An event resource may refer to one of these color IDs in its color field. Read-only.",
440
+     "additionalProperties": {
441
+      "$ref": "ColorDefinition",
442
+      "description": "An event color definition."
443
+     }
444
+    },
445
+    "kind": {
446
+     "type": "string",
447
+     "description": "Type of the resource (\"calendar#colors\").",
448
+     "default": "calendar#colors"
449
+    },
450
+    "updated": {
451
+     "type": "string",
452
+     "description": "Last modification time of the color palette (as a RFC 3339 timestamp). Read-only.",
453
+     "format": "date-time"
454
+    }
455
+   }
456
+  },
457
+  "Error": {
458
+   "id": "Error",
459
+   "type": "object",
460
+   "properties": {
461
+    "domain": {
462
+     "type": "string",
463
+     "description": "Domain, or broad category, of the error."
464
+    },
465
+    "reason": {
466
+     "type": "string",
467
+     "description": "Specific reason for the error. Some of the possible values are:  \n- \"groupTooBig\" - The group of users requested is too large for a single query. \n- \"tooManyCalendarsRequested\" - The number of calendars requested is too large for a single query. \n- \"notFound\" - The requested resource was not found. \n- \"internalError\" - The API service has encountered an internal error.  Additional error types may be added in the future, so clients should gracefully handle additional error statuses not included in this list."
468
+    }
469
+   }
470
+  },
471
+  "Event": {
472
+   "id": "Event",
473
+   "type": "object",
474
+   "properties": {
475
+    "anyoneCanAddSelf": {
476
+     "type": "boolean",
477
+     "description": "Whether anyone can invite themselves to the event. Optional. The default is False.",
478
+     "default": "false"
479
+    },
480
+    "attendees": {
481
+     "type": "array",
482
+     "description": "The attendees of the event.",
483
+     "items": {
484
+      "$ref": "EventAttendee"
485
+     }
486
+    },
487
+    "attendeesOmitted": {
488
+     "type": "boolean",
489
+     "description": "Whether attendees may have been omitted from the event's representation. When retrieving an event, this may be due to a restriction specified by the maxAttendee query parameter. When updating an event, this can be used to only update the participant's response. Optional. The default is False.",
490
+     "default": "false"
491
+    },
492
+    "colorId": {
493
+     "type": "string",
494
+     "description": "The color of the event. This is an ID referring to an entry in the event section of the colors definition (see the  colors endpoint). Optional."
495
+    },
496
+    "created": {
497
+     "type": "string",
498
+     "description": "Creation time of the event (as a RFC 3339 timestamp). Read-only.",
499
+     "format": "date-time"
500
+    },
501
+    "creator": {
502
+     "type": "object",
503
+     "description": "The creator of the event. Read-only.",
504
+     "properties": {
505
+      "displayName": {
506
+       "type": "string",
507
+       "description": "The creator's name, if available."
508
+      },
509
+      "email": {
510
+       "type": "string",
511
+       "description": "The creator's email address, if available."
512
+      },
513
+      "id": {
514
+       "type": "string",
515
+       "description": "The creator's Profile ID, if available."
516
+      },
517
+      "self": {
518
+       "type": "boolean",
519
+       "description": "Whether the creator corresponds to the calendar on which this copy of the event appears. Read-only. The default is False.",
520
+       "default": "false"
521
+      }
522
+     }
523
+    },
524
+    "description": {
525
+     "type": "string",
526
+     "description": "Description of the event. Optional."
527
+    },
528
+    "end": {
529
+     "$ref": "EventDateTime",
530
+     "description": "The (exclusive) end time of the event. For a recurring event, this is the end time of the first instance.",
531
+     "annotations": {
532
+      "required": [
533
+       "calendar.events.import",
534
+       "calendar.events.insert",
535
+       "calendar.events.update"
536
+      ]
537
+     }
538
+    },
539
+    "endTimeUnspecified": {
540
+     "type": "boolean",
541
+     "description": "Whether the end time is actually unspecified. An end time is still provided for compatibility reasons, even if this attribute is set to True. The default is False.",
542
+     "default": "false"
543
+    },
544
+    "etag": {
545
+     "type": "string",
546
+     "description": "ETag of the resource."
547
+    },
548
+    "extendedProperties": {
549
+     "type": "object",
550
+     "description": "Extended properties of the event.",
551
+     "properties": {
552
+      "private": {
553
+       "type": "object",
554
+       "description": "Properties that are private to the copy of the event that appears on this calendar.",
555
+       "additionalProperties": {
556
+        "type": "string",
557
+        "description": "The name of the private property and the corresponding value."
558
+       }
559
+      },
560
+      "shared": {
561
+       "type": "object",
562
+       "description": "Properties that are shared between copies of the event on other attendees' calendars.",
563
+       "additionalProperties": {
564
+        "type": "string",
565
+        "description": "The name of the shared property and the corresponding value."
566
+       }
567
+      }
568
+     }
569
+    },
570
+    "gadget": {
571
+     "type": "object",
572
+     "description": "A gadget that extends this event.",
573
+     "properties": {
574
+      "display": {
575
+       "type": "string",
576
+       "description": "The gadget's display mode. Optional. Possible values are:  \n- \"icon\" - The gadget displays next to the event's title in the calendar view. \n- \"chip\" - The gadget displays when the event is clicked."
577
+      },
578
+      "height": {
579
+       "type": "integer",
580
+       "description": "The gadget's height in pixels. Optional.",
581
+       "format": "int32"
582
+      },
583
+      "iconLink": {
584
+       "type": "string",
585
+       "description": "The gadget's icon URL."
586
+      },
587
+      "link": {
588
+       "type": "string",
589
+       "description": "The gadget's URL."
590
+      },
591
+      "preferences": {
592
+       "type": "object",
593
+       "description": "Preferences.",
594
+       "additionalProperties": {
595
+        "type": "string",
596
+        "description": "The preference name and corresponding value."
597
+       }
598
+      },
599
+      "title": {
600
+       "type": "string",
601
+       "description": "The gadget's title."
602
+      },
603
+      "type": {
604
+       "type": "string",
605
+       "description": "The gadget's type."
606
+      },
607
+      "width": {
608
+       "type": "integer",
609
+       "description": "The gadget's width in pixels. Optional.",
610
+       "format": "int32"
611
+      }
612
+     }
613
+    },
614
+    "guestsCanInviteOthers": {
615
+     "type": "boolean",
616
+     "description": "Whether attendees other than the organizer can invite others to the event. Optional. The default is True.",
617
+     "default": "true"
618
+    },
619
+    "guestsCanModify": {
620
+     "type": "boolean",
621
+     "description": "Whether attendees other than the organizer can modify the event. Optional. The default is False.",
622
+     "default": "false"
623
+    },
624
+    "guestsCanSeeOtherGuests": {
625
+     "type": "boolean",
626
+     "description": "Whether attendees other than the organizer can see who the event's attendees are. Optional. The default is True.",
627
+     "default": "true"
628
+    },
629
+    "hangoutLink": {
630
+     "type": "string",
631
+     "description": "An absolute link to the Google+ hangout associated with this event. Read-only."
632
+    },
633
+    "htmlLink": {
634
+     "type": "string",
635
+     "description": "An absolute link to this event in the Google Calendar Web UI. Read-only."
636
+    },
637
+    "iCalUID": {
638
+     "type": "string",
639
+     "description": "Event ID in the iCalendar format.",
640
+     "annotations": {
641
+      "required": [
642
+       "calendar.events.import"
643
+      ]
644
+     }
645
+    },
646
+    "id": {
647
+     "type": "string",
648
+     "description": "Identifier of the event. When creating new single or recurring events, you can specify their IDs. Provided IDs must follow these rules:  \n- characters allowed in the ID are those used in base32hex encoding, i.e. lowercase letters a-v and digits 0-9, see section 3.1.2 in RFC2938 \n- the length of the ID must be between 5 and 1024 characters \n- the ID must be unique per calendar  Due to the globally distributed nature of the system, we cannot guarantee that ID collisions will be detected at event creation time. To minimize the risk of collisions we recommend using an established UUID algorithm such as one described in RFC4122."
649
+    },
650
+    "kind": {
651
+     "type": "string",
652
+     "description": "Type of the resource (\"calendar#event\").",
653
+     "default": "calendar#event"
654
+    },
655
+    "location": {
656
+     "type": "string",
657
+     "description": "Geographic location of the event as free-form text. Optional."
658
+    },
659
+    "locked": {
660
+     "type": "boolean",
661
+     "description": "Whether this is a locked event copy where no changes can be made to the main event fields \"summary\", \"description\", \"location\", \"start\", \"end\" or \"recurrence\". The default is False. Read-Only.",
662
+     "default": "false"
663
+    },
664
+    "organizer": {
665
+     "type": "object",
666
+     "description": "The organizer of the event. If the organizer is also an attendee, this is indicated with a separate entry in attendees with the organizer field set to True. To change the organizer, use the move operation. Read-only, except when importing an event.",
667
+     "properties": {
668
+      "displayName": {
669
+       "type": "string",
670
+       "description": "The organizer's name, if available."
671
+      },
672
+      "email": {
673
+       "type": "string",
674
+       "description": "The organizer's email address, if available."
675
+      },
676
+      "id": {
677
+       "type": "string",
678
+       "description": "The organizer's Profile ID, if available."
679
+      },
680
+      "self": {
681
+       "type": "boolean",
682
+       "description": "Whether the organizer corresponds to the calendar on which this copy of the event appears. Read-only. The default is False.",
683
+       "default": "false"
684
+      }
685
+     }
686
+    },
687
+    "originalStartTime": {
688
+     "$ref": "EventDateTime",
689
+     "description": "For an instance of a recurring event, this is the time at which this event would start according to the recurrence data in the recurring event identified by recurringEventId. Immutable."
690
+    },
691
+    "privateCopy": {
692
+     "type": "boolean",
693
+     "description": "Whether this is a private event copy where changes are not shared with other copies on other calendars. Optional. Immutable. The default is False.",
694
+     "default": "false"
695
+    },
696
+    "recurrence": {
697
+     "type": "array",
698
+     "description": "List of RRULE, EXRULE, RDATE and EXDATE lines for a recurring event. This field is omitted for single events or instances of recurring events.",
699
+     "items": {
700
+      "type": "string"
701
+     }
702
+    },
703
+    "recurringEventId": {
704
+     "type": "string",
705
+     "description": "For an instance of a recurring event, this is the event ID of the recurring event itself. Immutable."
706
+    },
707
+    "reminders": {
708
+     "type": "object",
709
+     "description": "Information about the event's reminders for the authenticated user.",
710
+     "properties": {
711
+      "overrides": {
712
+       "type": "array",
713
+       "description": "If the event doesn't use the default reminders, this lists the reminders specific to the event, or, if not set, indicates that no reminders are set for this event.",
714
+       "items": {
715
+        "$ref": "EventReminder"
716
+       }
717
+      },
718
+      "useDefault": {
719
+       "type": "boolean",
720
+       "description": "Whether the default reminders of the calendar apply to the event."
721
+      }
722
+     }
723
+    },
724
+    "sequence": {
725
+     "type": "integer",
726
+     "description": "Sequence number as per iCalendar.",
727
+     "format": "int32"
728
+    },
729
+    "source": {
730
+     "type": "object",
731
+     "description": "Source of an event from which it was created; for example a web page, an email message or any document identifiable by an URL using HTTP/HTTPS protocol. Accessible only by the creator of the event.",
732
+     "properties": {
733
+      "title": {
734
+       "type": "string",
735
+       "description": "Title of the source; for example a title of a web page or an email subject."
736
+      },
737
+      "url": {
738
+       "type": "string",
739
+       "description": "URL of the source pointing to a resource. URL's protocol must be HTTP or HTTPS."
740
+      }
741
+     }
742
+    },
743
+    "start": {
744
+     "$ref": "EventDateTime",
745
+     "description": "The (inclusive) start time of the event. For a recurring event, this is the start time of the first instance.",
746
+     "annotations": {
747
+      "required": [
748
+       "calendar.events.import",
749
+       "calendar.events.insert",
750
+       "calendar.events.update"
751
+      ]
752
+     }
753
+    },
754
+    "status": {
755
+     "type": "string",
756
+     "description": "Status of the event. Optional. Possible values are:  \n- \"confirmed\" - The event is confirmed. This is the default status. \n- \"tentative\" - The event is tentatively confirmed. \n- \"cancelled\" - The event is cancelled."
757
+    },
758
+    "summary": {
759
+     "type": "string",
760
+     "description": "Title of the event."
761
+    },
762
+    "transparency": {
763
+     "type": "string",
764
+     "description": "Whether the event blocks time on the calendar. Optional. Possible values are:  \n- \"opaque\" - The event blocks time on the calendar. This is the default value. \n- \"transparent\" - The event does not block time on the calendar.",
765
+     "default": "opaque"
766
+    },
767
+    "updated": {
768
+     "type": "string",
769
+     "description": "Last modification time of the event (as a RFC 3339 timestamp). Read-only.",
770
+     "format": "date-time"
771
+    },
772
+    "visibility": {
773
+     "type": "string",
774
+     "description": "Visibility of the event. Optional. Possible values are:  \n- \"default\" - Uses the default visibility for events on the calendar. This is the default value. \n- \"public\" - The event is public and event details are visible to all readers of the calendar. \n- \"private\" - The event is private and only event attendees may view event details. \n- \"confidential\" - The event is private. This value is provided for compatibility reasons.",
775
+     "default": "default"
776
+    }
777
+   }
778
+  },
779
+  "EventAttendee": {
780
+   "id": "EventAttendee",
781
+   "type": "object",
782
+   "properties": {
783
+    "additionalGuests": {
784
+     "type": "integer",
785
+     "description": "Number of additional guests. Optional. The default is 0.",
786
+     "format": "int32"
787
+    },
788
+    "comment": {
789
+     "type": "string",
790
+     "description": "The attendee's response comment. Optional."
791
+    },
792
+    "displayName": {
793
+     "type": "string",
794
+     "description": "The attendee's name, if available. Optional."
795
+    },
796
+    "email": {
797
+     "type": "string",
798
+     "description": "The attendee's email address, if available. This field must be present when adding an attendee.",
799
+     "annotations": {
800
+      "required": [
801
+       "calendar.events.import",
802
+       "calendar.events.insert",
803
+       "calendar.events.update"
804
+      ]
805
+     }
806
+    },
807
+    "id": {
808
+     "type": "string",
809
+     "description": "The attendee's Profile ID, if available."
810
+    },
811
+    "optional": {
812
+     "type": "boolean",
813
+     "description": "Whether this is an optional attendee. Optional. The default is False."
814
+    },
815
+    "organizer": {
816
+     "type": "boolean",
817
+     "description": "Whether the attendee is the organizer of the event. Read-only. The default is False."
818
+    },
819
+    "resource": {
820
+     "type": "boolean",
821
+     "description": "Whether the attendee is a resource. Read-only. The default is False."
822
+    },
823
+    "responseStatus": {
824
+     "type": "string",
825
+     "description": "The attendee's response status. Possible values are:  \n- \"needsAction\" - The attendee has not responded to the invitation. \n- \"declined\" - The attendee has declined the invitation. \n- \"tentative\" - The attendee has tentatively accepted the invitation. \n- \"accepted\" - The attendee has accepted the invitation."
826
+    },
827
+    "self": {
828
+     "type": "boolean",
829
+     "description": "Whether this entry represents the calendar on which this copy of the event appears. Read-only. The default is False."
830
+    }
831
+   }
832
+  },
833
+  "EventDateTime": {
834
+   "id": "EventDateTime",
835
+   "type": "object",
836
+   "properties": {
837
+    "date": {
838
+     "type": "string",
839
+     "description": "The date, in the format \"yyyy-mm-dd\", if this is an all-day event.",
840
+     "format": "date"
841
+    },
842
+    "dateTime": {
843
+     "type": "string",
844
+     "description": "The time, as a combined date-time value (formatted according to RFC 3339). A time zone offset is required unless a time zone is explicitly specified in timeZone.",
845
+     "format": "date-time"
846
+    },
847
+    "timeZone": {
848
+     "type": "string",
849
+     "description": "The name of the time zone in which the time is specified (e.g. \"Europe/Zurich\"). Optional. The default is the time zone of the calendar."
850
+    }
851
+   }
852
+  },
853
+  "EventReminder": {
854
+   "id": "EventReminder",
855
+   "type": "object",
856
+   "properties": {
857
+    "method": {
858
+     "type": "string",
859
+     "description": "The method used by this reminder. Possible values are:  \n- \"email\" - Reminders are sent via email. \n- \"sms\" - Reminders are sent via SMS. \n- \"popup\" - Reminders are sent via a UI popup.",
860
+     "annotations": {
861
+      "required": [
862
+       "calendar.calendarList.insert",
863
+       "calendar.calendarList.update",
864
+       "calendar.events.import",
865
+       "calendar.events.insert",
866
+       "calendar.events.update"
867
+      ]
868
+     }
869
+    },
870
+    "minutes": {
871
+     "type": "integer",
872
+     "description": "Number of minutes before the start of the event when the reminder should trigger.",
873
+     "format": "int32",
874
+     "annotations": {
875
+      "required": [
876
+       "calendar.calendarList.insert",
877
+       "calendar.calendarList.update",
878
+       "calendar.events.import",
879
+       "calendar.events.insert",
880
+       "calendar.events.update"
881
+      ]
882
+     }
883
+    }
884
+   }
885
+  },
886
+  "Events": {
887
+   "id": "Events",
888
+   "type": "object",
889
+   "properties": {
890
+    "accessRole": {
891
+     "type": "string",
892
+     "description": "The user's access role for this calendar. Read-only. Possible values are:  \n- \"none\" - The user has no access. \n- \"freeBusyReader\" - The user has read access to free/busy information. \n- \"reader\" - The user has read access to the calendar. Private events will appear to users with reader access, but event details will be hidden. \n- \"writer\" - The user has read and write access to the calendar. Private events will appear to users with writer access, and event details will be visible. \n- \"owner\" - The user has ownership of the calendar. This role has all of the permissions of the writer role with the additional ability to see and manipulate ACLs."
893
+    },
894
+    "defaultReminders": {
895
+     "type": "array",
896
+     "description": "The default reminders on the calendar for the authenticated user. These reminders apply to all events on this calendar that do not explicitly override them (i.e. do not have reminders.useDefault set to True).",
897
+     "items": {
898
+      "$ref": "EventReminder"
899
+     }
900
+    },
901
+    "description": {
902
+     "type": "string",
903
+     "description": "Description of the calendar. Read-only."
904
+    },
905
+    "etag": {
906
+     "type": "string",
907
+     "description": "ETag of the collection."
908
+    },
909
+    "items": {
910
+     "type": "array",
911
+     "description": "List of events on the calendar.",
912
+     "items": {
913
+      "$ref": "Event"
914
+     }
915
+    },
916
+    "kind": {
917
+     "type": "string",
918
+     "description": "Type of the collection (\"calendar#events\").",
919
+     "default": "calendar#events"
920
+    },
921
+    "nextPageToken": {
922
+     "type": "string",
923
+     "description": "Token used to access the next page of this result. Omitted if no further results are available, in which case nextSyncToken is provided."
924
+    },
925
+    "nextSyncToken": {
926
+     "type": "string",
927
+     "description": "Token used at a later point in time to retrieve only the entries that have changed since this result was returned. Omitted if further results are available, in which case nextPageToken is provided."
928
+    },
929
+    "summary": {
930
+     "type": "string",
931
+     "description": "Title of the calendar. Read-only."
932
+    },
933
+    "timeZone": {
934
+     "type": "string",
935
+     "description": "The time zone of the calendar. Read-only."
936
+    },
937
+    "updated": {
938
+     "type": "string",
939
+     "description": "Last modification time of the calendar (as a RFC 3339 timestamp). Read-only.",
940
+     "format": "date-time"
941
+    }
942
+   }
943
+  },
944
+  "FreeBusyCalendar": {
945
+   "id": "FreeBusyCalendar",
946
+   "type": "object",
947
+   "properties": {
948
+    "busy": {
949
+     "type": "array",
950
+     "description": "List of time ranges during which this calendar should be regarded as busy.",
951
+     "items": {
952
+      "$ref": "TimePeriod"
953
+     }
954
+    },
955
+    "errors": {
956
+     "type": "array",
957
+     "description": "Optional error(s) (if computation for the calendar failed).",
958
+     "items": {
959
+      "$ref": "Error"
960
+     }
961
+    }
962
+   }
963
+  },
964
+  "FreeBusyGroup": {
965
+   "id": "FreeBusyGroup",
966
+   "type": "object",
967
+   "properties": {
968
+    "calendars": {
969
+     "type": "array",
970
+     "description": "List of calendars' identifiers within a group.",
971
+     "items": {
972
+      "type": "string"
973
+     }
974
+    },
975
+    "errors": {
976
+     "type": "array",
977
+     "description": "Optional error(s) (if computation for the group failed).",
978
+     "items": {
979
+      "$ref": "Error"
980
+     }
981
+    }
982
+   }
983
+  },
984
+  "FreeBusyRequest": {
985
+   "id": "FreeBusyRequest",
986
+   "type": "object",
987
+   "properties": {
988
+    "calendarExpansionMax": {
989
+     "type": "integer",
990
+     "description": "Maximal number of calendars for which FreeBusy information is to be provided. Optional.",
991
+     "format": "int32"
992
+    },
993
+    "groupExpansionMax": {
994
+     "type": "integer",
995
+     "description": "Maximal number of calendar identifiers to be provided for a single group. Optional. An error will be returned for a group with more members than this value.",
996
+     "format": "int32"
997
+    },
998
+    "items": {
999
+     "type": "array",
1000
+     "description": "List of calendars and/or groups to query.",
1001
+     "items": {
1002
+      "$ref": "FreeBusyRequestItem"
1003
+     }
1004
+    },
1005
+    "timeMax": {
1006
+     "type": "string",
1007
+     "description": "The end of the interval for the query.",
1008
+     "format": "date-time"
1009
+    },
1010
+    "timeMin": {
1011
+     "type": "string",
1012
+     "description": "The start of the interval for the query.",
1013
+     "format": "date-time"
1014
+    },
1015
+    "timeZone": {
1016
+     "type": "string",
1017
+     "description": "Time zone used in the response. Optional. The default is UTC.",
1018
+     "default": "UTC"
1019
+    }
1020
+   }
1021
+  },
1022
+  "FreeBusyRequestItem": {
1023
+   "id": "FreeBusyRequestItem",
1024
+   "type": "object",
1025
+   "properties": {
1026
+    "id": {
1027
+     "type": "string",
1028
+     "description": "The identifier of a calendar or a group."
1029
+    }
1030
+   }
1031
+  },
1032
+  "FreeBusyResponse": {
1033
+   "id": "FreeBusyResponse",
1034
+   "type": "object",
1035
+   "properties": {
1036
+    "calendars": {
1037
+     "type": "object",
1038
+     "description": "List of free/busy information for calendars.",
1039
+     "additionalProperties": {
1040
+      "$ref": "FreeBusyCalendar",
1041
+      "description": "Free/busy expansions for a single calendar."
1042
+     }
1043
+    },
1044
+    "groups": {
1045
+     "type": "object",
1046
+     "description": "Expansion of groups.",
1047
+     "additionalProperties": {
1048
+      "$ref": "FreeBusyGroup",
1049
+      "description": "List of calendars that are members of this group."
1050
+     }
1051
+    },
1052
+    "kind": {
1053
+     "type": "string",
1054
+     "description": "Type of the resource (\"calendar#freeBusy\").",
1055
+     "default": "calendar#freeBusy"
1056
+    },
1057
+    "timeMax": {
1058
+     "type": "string",
1059
+     "description": "The end of the interval.",
1060
+     "format": "date-time"
1061
+    },
1062
+    "timeMin": {
1063
+     "type": "string",
1064
+     "description": "The start of the interval.",
1065
+     "format": "date-time"
1066
+    }
1067
+   }
1068
+  },
1069
+  "Setting": {
1070
+   "id": "Setting",
1071
+   "type": "object",
1072
+   "properties": {
1073
+    "etag": {
1074
+     "type": "string",
1075
+     "description": "ETag of the resource."
1076
+    },
1077
+    "id": {
1078
+     "type": "string",
1079
+     "description": "The id of the user setting."
1080
+    },
1081
+    "kind": {
1082
+     "type": "string",
1083
+     "description": "Type of the resource (\"calendar#setting\").",
1084
+     "default": "calendar#setting"
1085
+    },
1086
+    "value": {
1087
+     "type": "string",
1088
+     "description": "Value of the user setting. The format of the value depends on the ID of the setting. It must always be a UTF-8 string of length up to 1024 characters."
1089
+    }
1090
+   }
1091
+  },
1092
+  "Settings": {
1093
+   "id": "Settings",
1094
+   "type": "object",
1095
+   "properties": {
1096
+    "etag": {
1097
+     "type": "string",
1098
+     "description": "Etag of the collection."
1099
+    },
1100
+    "items": {
1101
+     "type": "array",
1102
+     "description": "List of user settings.",
1103
+     "items": {
1104
+      "$ref": "Setting"
1105
+     }
1106
+    },
1107
+    "kind": {
1108
+     "type": "string",
1109
+     "description": "Type of the collection (\"calendar#settings\").",
1110
+     "default": "calendar#settings"
1111
+    },
1112
+    "nextPageToken": {
1113
+     "type": "string",
1114
+     "description": "Token used to access the next page of this result. Omitted if no further results are available, in which case nextSyncToken is provided."
1115
+    },
1116
+    "nextSyncToken": {
1117
+     "type": "string",
1118
+     "description": "Token used at a later point in time to retrieve only the entries that have changed since this result was returned. Omitted if further results are available, in which case nextPageToken is provided."
1119
+    }
1120
+   }
1121
+  },
1122
+  "TimePeriod": {
1123
+   "id": "TimePeriod",
1124
+   "type": "object",
1125
+   "properties": {
1126
+    "end": {
1127
+     "type": "string",
1128
+     "description": "The (exclusive) end of the time period.",
1129
+     "format": "date-time"
1130
+    },
1131
+    "start": {
1132
+     "type": "string",
1133
+     "description": "The (inclusive) start of the time period.",
1134
+     "format": "date-time"
1135
+    }
1136
+   }
1137
+  }
1138
+ },
1139
+ "resources": {
1140
+  "acl": {
1141
+   "methods": {
1142
+    "delete": {
1143
+     "id": "calendar.acl.delete",
1144
+     "path": "calendars/{calendarId}/acl/{ruleId}",
1145
+     "httpMethod": "DELETE",
1146
+     "description": "Deletes an access control rule.",
1147
+     "parameters": {
1148
+      "calendarId": {
1149
+       "type": "string",
1150
+       "description": "Calendar identifier.",
1151
+       "required": true,
1152
+       "location": "path"
1153
+      },
1154
+      "ruleId": {
1155
+       "type": "string",
1156
+       "description": "ACL rule identifier.",
1157
+       "required": true,
1158
+       "location": "path"
1159
+      }
1160
+     },
1161
+     "parameterOrder": [
1162
+      "calendarId",
1163
+      "ruleId"
1164
+     ],
1165
+     "scopes": [
1166
+      "https://www.googleapis.com/auth/calendar"
1167
+     ]
1168
+    },
1169
+    "get": {
1170
+     "id": "calendar.acl.get",
1171
+     "path": "calendars/{calendarId}/acl/{ruleId}",
1172
+     "httpMethod": "GET",
1173
+     "description": "Returns an access control rule.",
1174
+     "parameters": {
1175
+      "calendarId": {
1176
+       "type": "string",
1177
+       "description": "Calendar identifier.",
1178
+       "required": true,
1179
+       "location": "path"
1180
+      },
1181
+      "ruleId": {
1182
+       "type": "string",
1183
+       "description": "ACL rule identifier.",
1184
+       "required": true,
1185
+       "location": "path"
1186
+      }
1187
+     },
1188
+     "parameterOrder": [
1189
+      "calendarId",
1190
+      "ruleId"
1191
+     ],
1192
+     "response": {
1193
+      "$ref": "AclRule"
1194
+     },
1195
+     "scopes": [
1196
+      "https://www.googleapis.com/auth/calendar",
1197
+      "https://www.googleapis.com/auth/calendar.readonly"
1198
+     ]
1199
+    },
1200
+    "insert": {
1201
+     "id": "calendar.acl.insert",
1202
+     "path": "calendars/{calendarId}/acl",
1203
+     "httpMethod": "POST",
1204
+     "description": "Creates an access control rule.",
1205
+     "parameters": {
1206
+      "calendarId": {
1207
+       "type": "string",
1208
+       "description": "Calendar identifier.",
1209
+       "required": true,
1210
+       "location": "path"
1211
+      }
1212
+     },
1213
+     "parameterOrder": [
1214
+      "calendarId"
1215
+     ],
1216
+     "request": {
1217
+      "$ref": "AclRule"
1218
+     },
1219
+     "response": {
1220
+      "$ref": "AclRule"
1221
+     },
1222
+     "scopes": [
1223
+      "https://www.googleapis.com/auth/calendar"
1224
+     ]
1225
+    },
1226
+    "list": {
1227
+     "id": "calendar.acl.list",
1228
+     "path": "calendars/{calendarId}/acl",
1229
+     "httpMethod": "GET",
1230
+     "description": "Returns the rules in the access control list for the calendar.",
1231
+     "parameters": {
1232
+      "calendarId": {
1233
+       "type": "string",
1234
+       "description": "Calendar identifier.",
1235
+       "required": true,
1236
+       "location": "path"
1237
+      },
1238
+      "maxResults": {
1239
+       "type": "integer",
1240
+       "description": "Maximum number of entries returned on one result page. By default the value is 100 entries. The page size can never be larger than 250 entries. Optional.",
1241
+       "format": "int32",
1242
+       "minimum": "1",
1243
+       "location": "query"
1244
+      },
1245
+      "pageToken": {
1246
+       "type": "string",
1247
+       "description": "Token specifying which result page to return. Optional.",
1248
+       "location": "query"
1249
+      },
1250
+      "showDeleted": {
1251
+       "type": "boolean",
1252
+       "description": "Whether to include deleted ACLs in the result. Deleted ACLs are represented by role equal to \"none\". Deleted ACLs will always be included if syncToken is provided. Optional. The default is False.",
1253
+       "location": "query"
1254
+      },
1255
+      "syncToken": {
1256
+       "type": "string",
1257
+       "description": "Token obtained from the nextSyncToken field returned on the last page of results from the previous list request. It makes the result of this list request contain only entries that have changed since then. All entries deleted since the previous list request will always be in the result set and it is not allowed to set showDeleted to False.\nIf the syncToken expires, the server will respond with a 410 GONE response code and the client should clear its storage and perform a full synchronization without any syncToken.\nLearn more about incremental synchronization.\nOptional. The default is to return all entries.",
1258
+       "location": "query"
1259
+      }
1260
+     },
1261
+     "parameterOrder": [
1262
+      "calendarId"
1263
+     ],
1264
+     "response": {
1265
+      "$ref": "Acl"
1266
+     },
1267
+     "scopes": [
1268
+      "https://www.googleapis.com/auth/calendar"
1269
+     ],
1270
+     "supportsSubscription": true
1271
+    },
1272
+    "patch": {
1273
+     "id": "calendar.acl.patch",
1274
+     "path": "calendars/{calendarId}/acl/{ruleId}",
1275
+     "httpMethod": "PATCH",
1276
+     "description": "Updates an access control rule. This method supports patch semantics.",
1277
+     "parameters": {
1278
+      "calendarId": {
1279
+       "type": "string",
1280
+       "description": "Calendar identifier.",
1281
+       "required": true,
1282
+       "location": "path"
1283
+      },
1284
+      "ruleId": {
1285
+       "type": "string",
1286
+       "description": "ACL rule identifier.",
1287
+       "required": true,
1288
+       "location": "path"
1289
+      }
1290
+     },
1291
+     "parameterOrder": [
1292
+      "calendarId",
1293
+      "ruleId"
1294
+     ],
1295
+     "request": {
1296
+      "$ref": "AclRule"
1297
+     },
1298
+     "response": {
1299
+      "$ref": "AclRule"
1300
+     },
1301
+     "scopes": [
1302
+      "https://www.googleapis.com/auth/calendar"
1303
+     ]
1304
+    },
1305
+    "update": {
1306
+     "id": "calendar.acl.update",
1307
+     "path": "calendars/{calendarId}/acl/{ruleId}",
1308
+     "httpMethod": "PUT",
1309
+     "description": "Updates an access control rule.",
1310
+     "parameters": {
1311
+      "calendarId": {
1312
+       "type": "string",
1313
+       "description": "Calendar identifier.",
1314
+       "required": true,
1315
+       "location": "path"
1316
+      },
1317
+      "ruleId": {
1318
+       "type": "string",
1319
+       "description": "ACL rule identifier.",
1320
+       "required": true,
1321
+       "location": "path"
1322
+      }
1323
+     },
1324
+     "parameterOrder": [
1325
+      "calendarId",
1326
+      "ruleId"
1327
+     ],
1328
+     "request": {
1329
+      "$ref": "AclRule"
1330
+     },
1331
+     "response": {
1332
+      "$ref": "AclRule"
1333
+     },
1334
+     "scopes": [
1335
+      "https://www.googleapis.com/auth/calendar"
1336
+     ]
1337
+    },
1338
+    "watch": {
1339
+     "id": "calendar.acl.watch",
1340
+     "path": "calendars/{calendarId}/acl/watch",
1341
+     "httpMethod": "POST",
1342
+     "description": "Watch for changes to ACL resources.",
1343
+     "parameters": {
1344
+      "calendarId": {
1345
+       "type": "string",
1346
+       "description": "Calendar identifier.",
1347
+       "required": true,
1348
+       "location": "path"
1349
+      },
1350
+      "maxResults": {
1351
+       "type": "integer",
1352
+       "description": "Maximum number of entries returned on one result page. By default the value is 100 entries. The page size can never be larger than 250 entries. Optional.",
1353
+       "format": "int32",
1354
+       "minimum": "1",
1355
+       "location": "query"
1356
+      },
1357
+      "pageToken": {
1358
+       "type": "string",
1359
+       "description": "Token specifying which result page to return. Optional.",
1360
+       "location": "query"
1361
+      },
1362
+      "showDeleted": {
1363
+       "type": "boolean",
1364
+       "description": "Whether to include deleted ACLs in the result. Deleted ACLs are represented by role equal to \"none\". Deleted ACLs will always be included if syncToken is provided. Optional. The default is False.",
1365
+       "location": "query"
1366
+      },
1367
+      "syncToken": {
1368
+       "type": "string",
1369
+       "description": "Token obtained from the nextSyncToken field returned on the last page of results from the previous list request. It makes the result of this list request contain only entries that have changed since then. All entries deleted since the previous list request will always be in the result set and it is not allowed to set showDeleted to False.\nIf the syncToken expires, the server will respond with a 410 GONE response code and the client should clear its storage and perform a full synchronization without any syncToken.\nLearn more about incremental synchronization.\nOptional. The default is to return all entries.",
1370
+       "location": "query"
1371
+      }
1372
+     },
1373
+     "parameterOrder": [
1374
+      "calendarId"
1375
+     ],
1376
+     "request": {
1377
+      "$ref": "Channel",
1378
+      "parameterName": "resource"
1379
+     },
1380
+     "response": {
1381
+      "$ref": "Channel"
1382
+     },
1383
+     "scopes": [
1384
+      "https://www.googleapis.com/auth/calendar"
1385
+     ],
1386
+     "supportsSubscription": true
1387
+    }
1388
+   }
1389
+  },
1390
+  "calendarList": {
1391
+   "methods": {
1392
+    "delete": {
1393
+     "id": "calendar.calendarList.delete",
1394
+     "path": "users/me/calendarList/{calendarId}",
1395
+     "httpMethod": "DELETE",
1396
+     "description": "Deletes an entry on the user's calendar list.",
1397
+     "parameters": {
1398
+      "calendarId": {
1399
+       "type": "string",
1400
+       "description": "Calendar identifier.",
1401
+       "required": true,
1402
+       "location": "path"
1403
+      }
1404
+     },
1405
+     "parameterOrder": [
1406
+      "calendarId"
1407
+     ],
1408
+     "scopes": [
1409
+      "https://www.googleapis.com/auth/calendar"
1410
+     ]
1411
+    },
1412
+    "get": {
1413
+     "id": "calendar.calendarList.get",
1414
+     "path": "users/me/calendarList/{calendarId}",
1415
+     "httpMethod": "GET",
1416
+     "description": "Returns an entry on the user's calendar list.",
1417
+     "parameters": {
1418
+      "calendarId": {
1419
+       "type": "string",
1420
+       "description": "Calendar identifier.",
1421
+       "required": true,
1422
+       "location": "path"
1423
+      }
1424
+     },
1425
+     "parameterOrder": [
1426
+      "calendarId"
1427
+     ],
1428
+     "response": {
1429
+      "$ref": "CalendarListEntry"
1430
+     },
1431
+     "scopes": [
1432
+      "https://www.googleapis.com/auth/calendar",
1433
+      "https://www.googleapis.com/auth/calendar.readonly"
1434
+     ]
1435
+    },
1436
+    "insert": {
1437
+     "id": "calendar.calendarList.insert",
1438
+     "path": "users/me/calendarList",
1439
+     "httpMethod": "POST",
1440
+     "description": "Adds an entry to the user's calendar list.",
1441
+     "parameters": {
1442
+      "colorRgbFormat": {
1443
+       "type": "boolean",
1444
+       "description": "Whether to use the foregroundColor and backgroundColor fields to write the calendar colors (RGB). If this feature is used, the index-based colorId field will be set to the best matching option automatically. Optional. The default is False.",
1445
+       "location": "query"
1446
+      }
1447
+     },
1448
+     "request": {
1449
+      "$ref": "CalendarListEntry"
1450
+     },
1451
+     "response": {
1452
+      "$ref": "CalendarListEntry"
1453
+     },
1454
+     "scopes": [
1455
+      "https://www.googleapis.com/auth/calendar"
1456
+     ]
1457
+    },
1458
+    "list": {
1459
+     "id": "calendar.calendarList.list",
1460
+     "path": "users/me/calendarList",
1461
+     "httpMethod": "GET",
1462
+     "description": "Returns entries on the user's calendar list.",
1463
+     "parameters": {
1464
+      "maxResults": {
1465
+       "type": "integer",
1466
+       "description": "Maximum number of entries returned on one result page. By default the value is 100 entries. The page size can never be larger than 250 entries. Optional.",
1467
+       "format": "int32",
1468
+       "minimum": "1",
1469
+       "location": "query"
1470
+      },
1471
+      "minAccessRole": {
1472
+       "type": "string",
1473
+       "description": "The minimum access role for the user in the returned entires. Optional. The default is no restriction.",
1474
+       "enum": [
1475
+        "freeBusyReader",
1476
+        "owner",
1477
+        "reader",
1478
+        "writer"
1479
+       ],
1480
+       "enumDescriptions": [
1481
+        "The user can read free/busy information.",
1482
+        "The user can read and modify events and access control lists.",
1483
+        "The user can read events that are not private.",
1484
+        "The user can read and modify events."
1485
+       ],
1486
+       "location": "query"
1487
+      },
1488
+      "pageToken": {
1489
+       "type": "string",
1490
+       "description": "Token specifying which result page to return. Optional.",
1491
+       "location": "query"
1492
+      },
1493
+      "showDeleted": {
1494
+       "type": "boolean",
1495
+       "description": "Whether to include deleted calendar list entries in the result. Optional. The default is False.",
1496
+       "location": "query"
1497
+      },
1498
+      "showHidden": {
1499
+       "type": "boolean",
1500
+       "description": "Whether to show hidden entries. Optional. The default is False.",
1501
+       "location": "query"
1502
+      },
1503
+      "syncToken": {
1504
+       "type": "string",
1505
+       "description": "Token obtained from the nextSyncToken field returned on the last page of results from the previous list request. It makes the result of this list request contain only entries that have changed since then. If only read-only fields such as calendar properties or ACLs have changed, the entry won't be returned. All entries deleted and hidden since the previous list request will always be in the result set and it is not allowed to set showDeleted neither showHidden to False.\nTo ensure client state consistency minAccessRole query parameter cannot be specified together with nextSyncToken.\nIf the syncToken expires, the server will respond with a 410 GONE response code and the client should clear its storage and perform a full synchronization without any syncToken.\nLearn more about incremental synchronization.\nOptional. The default is to return all entries.",
1506
+       "location": "query"
1507
+      }
1508
+     },
1509
+     "response": {
1510
+      "$ref": "CalendarList"
1511
+     },
1512
+     "scopes": [
1513
+      "https://www.googleapis.com/auth/calendar",
1514
+      "https://www.googleapis.com/auth/calendar.readonly"
1515
+     ],
1516
+     "supportsSubscription": true
1517
+    },
1518
+    "patch": {
1519
+     "id": "calendar.calendarList.patch",
1520
+     "path": "users/me/calendarList/{calendarId}",
1521
+     "httpMethod": "PATCH",
1522
+     "description": "Updates an entry on the user's calendar list. This method supports patch semantics.",
1523
+     "parameters": {
1524
+      "calendarId": {
1525
+       "type": "string",
1526
+       "description": "Calendar identifier.",
1527
+       "required": true,
1528
+       "location": "path"
1529
+      },
1530
+      "colorRgbFormat": {
1531
+       "type": "boolean",
1532
+       "description": "Whether to use the foregroundColor and backgroundColor fields to write the calendar colors (RGB). If this feature is used, the index-based colorId field will be set to the best matching option automatically. Optional. The default is False.",
1533
+       "location": "query"
1534
+      }
1535
+     },
1536
+     "parameterOrder": [
1537
+      "calendarId"
1538
+     ],
1539
+     "request": {
1540
+      "$ref": "CalendarListEntry"
1541
+     },
1542
+     "response": {
1543
+      "$ref": "CalendarListEntry"
1544
+     },
1545
+     "scopes": [
1546
+      "https://www.googleapis.com/auth/calendar"
1547
+     ]
1548
+    },
1549
+    "update": {
1550
+     "id": "calendar.calendarList.update",
1551
+     "path": "users/me/calendarList/{calendarId}",
1552
+     "httpMethod": "PUT",
1553
+     "description": "Updates an entry on the user's calendar list.",
1554
+     "parameters": {
1555
+      "calendarId": {
1556
+       "type": "string",
1557
+       "description": "Calendar identifier.",
1558
+       "required": true,
1559
+       "location": "path"
1560
+      },
1561
+      "colorRgbFormat": {
1562
+       "type": "boolean",
1563
+       "description": "Whether to use the foregroundColor and backgroundColor fields to write the calendar colors (RGB). If this feature is used, the index-based colorId field will be set to the best matching option automatically. Optional. The default is False.",
1564
+       "location": "query"
1565
+      }
1566
+     },
1567
+     "parameterOrder": [
1568
+      "calendarId"
1569
+     ],
1570
+     "request": {
1571
+      "$ref": "CalendarListEntry"
1572
+     },
1573
+     "response": {
1574
+      "$ref": "CalendarListEntry"
1575
+     },
1576
+     "scopes": [
1577
+      "https://www.googleapis.com/auth/calendar"
1578
+     ]
1579
+    },
1580
+    "watch": {
1581
+     "id": "calendar.calendarList.watch",
1582
+     "path": "users/me/calendarList/watch",
1583
+     "httpMethod": "POST",
1584
+     "description": "Watch for changes to CalendarList resources.",
1585
+     "parameters": {
1586
+      "maxResults": {
1587
+       "type": "integer",
1588
+       "description": "Maximum number of entries returned on one result page. By default the value is 100 entries. The page size can never be larger than 250 entries. Optional.",
1589
+       "format": "int32",
1590
+       "minimum": "1",
1591
+       "location": "query"
1592
+      },
1593
+      "minAccessRole": {
1594
+       "type": "string",
1595
+       "description": "The minimum access role for the user in the returned entires. Optional. The default is no restriction.",
1596
+       "enum": [
1597
+        "freeBusyReader",
1598
+        "owner",
1599
+        "reader",
1600
+        "writer"
1601
+       ],
1602
+       "enumDescriptions": [
1603
+        "The user can read free/busy information.",
1604
+        "The user can read and modify events and access control lists.",
1605
+        "The user can read events that are not private.",
1606
+        "The user can read and modify events."
1607
+       ],
1608
+       "location": "query"
1609
+      },
1610
+      "pageToken": {
1611
+       "type": "string",
1612
+       "description": "Token specifying which result page to return. Optional.",
1613
+       "location": "query"
1614
+      },
1615
+      "showDeleted": {
1616
+       "type": "boolean",
1617
+       "description": "Whether to include deleted calendar list entries in the result. Optional. The default is False.",
1618
+       "location": "query"
1619
+      },
1620
+      "showHidden": {
1621
+       "type": "boolean",
1622
+       "description": "Whether to show hidden entries. Optional. The default is False.",
1623
+       "location": "query"
1624
+      },
1625
+      "syncToken": {
1626
+       "type": "string",
1627
+       "description": "Token obtained from the nextSyncToken field returned on the last page of results from the previous list request. It makes the result of this list request contain only entries that have changed since then. If only read-only fields such as calendar properties or ACLs have changed, the entry won't be returned. All entries deleted and hidden since the previous list request will always be in the result set and it is not allowed to set showDeleted neither showHidden to False.\nTo ensure client state consistency minAccessRole query parameter cannot be specified together with nextSyncToken.\nIf the syncToken expires, the server will respond with a 410 GONE response code and the client should clear its storage and perform a full synchronization without any syncToken.\nLearn more about incremental synchronization.\nOptional. The default is to return all entries.",
1628
+       "location": "query"
1629
+      }
1630
+     },
1631
+     "request": {
1632
+      "$ref": "Channel",
1633
+      "parameterName": "resource"
1634
+     },
1635
+     "response": {
1636
+      "$ref": "Channel"
1637
+     },
1638
+     "scopes": [
1639
+      "https://www.googleapis.com/auth/calendar",
1640
+      "https://www.googleapis.com/auth/calendar.readonly"
1641
+     ],
1642
+     "supportsSubscription": true
1643
+    }
1644
+   }
1645
+  },
1646
+  "calendars": {
1647
+   "methods": {
1648
+    "clear": {
1649
+     "id": "calendar.calendars.clear",
1650
+     "path": "calendars/{calendarId}/clear",
1651
+     "httpMethod": "POST",
1652
+     "description": "Clears a primary calendar. This operation deletes all data associated with the primary calendar of an account and cannot be undone.",
1653
+     "parameters": {
1654
+      "calendarId": {
1655
+       "type": "string",
1656
+       "description": "Calendar identifier.",
1657
+       "required": true,
1658
+       "location": "path"
1659
+      }
1660
+     },
1661
+     "parameterOrder": [
1662
+      "calendarId"
1663
+     ],
1664
+     "scopes": [
1665
+      "https://www.googleapis.com/auth/calendar"
1666
+     ]
1667
+    },
1668
+    "delete": {
1669
+     "id": "calendar.calendars.delete",
1670
+     "path": "calendars/{calendarId}",
1671
+     "httpMethod": "DELETE",
1672
+     "description": "Deletes a secondary calendar.",
1673
+     "parameters": {
1674
+      "calendarId": {
1675
+       "type": "string",
1676
+       "description": "Calendar identifier.",
1677
+       "required": true,
1678
+       "location": "path"
1679
+      }
1680
+     },
1681
+     "parameterOrder": [
1682
+      "calendarId"
1683
+     ],
1684
+     "scopes": [
1685
+      "https://www.googleapis.com/auth/calendar"
1686
+     ]
1687
+    },
1688
+    "get": {
1689
+     "id": "calendar.calendars.get",
1690
+     "path": "calendars/{calendarId}",
1691
+     "httpMethod": "GET",
1692
+     "description": "Returns metadata for a calendar.",
1693
+     "parameters": {
1694
+      "calendarId": {
1695
+       "type": "string",
1696
+       "description": "Calendar identifier.",
1697
+       "required": true,
1698
+       "location": "path"
1699
+      }
1700
+     },
1701
+     "parameterOrder": [
1702
+      "calendarId"
1703
+     ],
1704
+     "response": {
1705
+      "$ref": "Calendar"
1706
+     },
1707
+     "scopes": [
1708
+      "https://www.googleapis.com/auth/calendar",
1709
+      "https://www.googleapis.com/auth/calendar.readonly"
1710
+     ]
1711
+    },
1712
+    "insert": {
1713
+     "id": "calendar.calendars.insert",
1714
+     "path": "calendars",
1715
+     "httpMethod": "POST",
1716
+     "description": "Creates a secondary calendar.",
1717
+     "request": {
1718
+      "$ref": "Calendar"
1719
+     },
1720
+     "response": {
1721
+      "$ref": "Calendar"
1722
+     },
1723
+     "scopes": [
1724
+      "https://www.googleapis.com/auth/calendar"
1725
+     ]
1726
+    },
1727
+    "patch": {
1728
+     "id": "calendar.calendars.patch",
1729
+     "path": "calendars/{calendarId}",
1730
+     "httpMethod": "PATCH",
1731
+     "description": "Updates metadata for a calendar. This method supports patch semantics.",
1732
+     "parameters": {
1733
+      "calendarId": {
1734
+       "type": "string",
1735
+       "description": "Calendar identifier.",
1736
+       "required": true,
1737
+       "location": "path"
1738
+      }
1739
+     },
1740
+     "parameterOrder": [
1741
+      "calendarId"
1742
+     ],
1743
+     "request": {
1744
+      "$ref": "Calendar"
1745
+     },
1746
+     "response": {
1747
+      "$ref": "Calendar"
1748
+     },
1749
+     "scopes": [
1750
+      "https://www.googleapis.com/auth/calendar"
1751
+     ]
1752
+    },
1753
+    "update": {
1754
+     "id": "calendar.calendars.update",
1755
+     "path": "calendars/{calendarId}",
1756
+     "httpMethod": "PUT",
1757
+     "description": "Updates metadata for a calendar.",
1758
+     "parameters": {
1759
+      "calendarId": {
1760
+       "type": "string",
1761
+       "description": "Calendar identifier.",
1762
+       "required": true,
1763
+       "location": "path"
1764
+      }
1765
+     },
1766
+     "parameterOrder": [
1767
+      "calendarId"
1768
+     ],
1769
+     "request": {
1770
+      "$ref": "Calendar"
1771
+     },
1772
+     "response": {
1773
+      "$ref": "Calendar"
1774
+     },
1775
+     "scopes": [
1776
+      "https://www.googleapis.com/auth/calendar"
1777
+     ]
1778
+    }
1779
+   }
1780
+  },
1781
+  "channels": {
1782
+   "methods": {
1783
+    "stop": {
1784
+     "id": "calendar.channels.stop",
1785
+     "path": "channels/stop",
1786
+     "httpMethod": "POST",
1787
+     "description": "Stop watching resources through this channel",
1788
+     "request": {
1789
+      "$ref": "Channel",
1790
+      "parameterName": "resource"
1791
+     },
1792
+     "scopes": [
1793
+      "https://www.googleapis.com/auth/calendar",
1794
+      "https://www.googleapis.com/auth/calendar.readonly"
1795
+     ]
1796
+    }
1797
+   }
1798
+  },
1799
+  "colors": {
1800
+   "methods": {
1801
+    "get": {
1802
+     "id": "calendar.colors.get",
1803
+     "path": "colors",
1804
+     "httpMethod": "GET",
1805
+     "description": "Returns the color definitions for calendars and events.",
1806
+     "response": {
1807
+      "$ref": "Colors"
1808
+     },
1809
+     "scopes": [
1810
+      "https://www.googleapis.com/auth/calendar",
1811
+      "https://www.googleapis.com/auth/calendar.readonly"
1812
+     ]
1813
+    }
1814
+   }
1815
+  },
1816
+  "events": {
1817
+   "methods": {
1818
+    "delete": {
1819
+     "id": "calendar.events.delete",
1820
+     "path": "calendars/{calendarId}/events/{eventId}",
1821
+     "httpMethod": "DELETE",
1822
+     "description": "Deletes an event.",
1823
+     "parameters": {
1824
+      "calendarId": {
1825
+       "type": "string",
1826
+       "description": "Calendar identifier.",
1827
+       "required": true,
1828
+       "location": "path"
1829
+      },
1830
+      "eventId": {
1831
+       "type": "string",
1832
+       "description": "Event identifier.",
1833
+       "required": true,
1834
+       "location": "path"
1835
+      },
1836
+      "sendNotifications": {
1837
+       "type": "boolean",
1838
+       "description": "Whether to send notifications about the deletion of the event. Optional. The default is False.",
1839
+       "location": "query"
1840
+      }
1841
+     },
1842
+     "parameterOrder": [
1843
+      "calendarId",
1844
+      "eventId"
1845
+     ],
1846
+     "scopes": [
1847
+      "https://www.googleapis.com/auth/calendar"
1848
+     ]
1849
+    },
1850
+    "get": {
1851
+     "id": "calendar.events.get",
1852
+     "path": "calendars/{calendarId}/events/{eventId}",
1853
+     "httpMethod": "GET",
1854
+     "description": "Returns an event.",
1855
+     "parameters": {
1856
+      "alwaysIncludeEmail": {
1857
+       "type": "boolean",
1858
+       "description": "Whether to always include a value in the email field for the organizer, creator and attendees, even if no real email is available (i.e. a generated, non-working value will be provided). The use of this option is discouraged and should only be used by clients which cannot handle the absence of an email address value in the mentioned places. Optional. The default is False.",
1859
+       "location": "query"
1860
+      },
1861
+      "calendarId": {
1862
+       "type": "string",
1863
+       "description": "Calendar identifier.",
1864
+       "required": true,
1865
+       "location": "path"
1866
+      },
1867
+      "eventId": {
1868
+       "type": "string",
1869
+       "description": "Event identifier.",
1870
+       "required": true,
1871
+       "location": "path"
1872
+      },
1873
+      "maxAttendees": {
1874
+       "type": "integer",
1875
+       "description": "The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional.",
1876
+       "format": "int32",
1877
+       "minimum": "1",
1878
+       "location": "query"
1879
+      },
1880
+      "timeZone": {
1881
+       "type": "string",
1882
+       "description": "Time zone used in the response. Optional. The default is the time zone of the calendar.",
1883
+       "location": "query"
1884
+      }
1885
+     },
1886
+     "parameterOrder": [
1887
+      "calendarId",
1888
+      "eventId"
1889
+     ],
1890
+     "response": {
1891
+      "$ref": "Event"
1892
+     },
1893
+     "scopes": [
1894
+      "https://www.googleapis.com/auth/calendar",
1895
+      "https://www.googleapis.com/auth/calendar.readonly"
1896
+     ]
1897
+    },
1898
+    "import": {
1899
+     "id": "calendar.events.import",
1900
+     "path": "calendars/{calendarId}/events/import",
1901
+     "httpMethod": "POST",
1902
+     "description": "Imports an event. This operation is used to add a private copy of an existing event to a calendar.",
1903
+     "parameters": {
1904
+      "calendarId": {
1905
+       "type": "string",
1906
+       "description": "Calendar identifier.",
1907
+       "required": true,
1908
+       "location": "path"
1909
+      }
1910
+     },
1911
+     "parameterOrder": [
1912
+      "calendarId"
1913
+     ],
1914
+     "request": {
1915
+      "$ref": "Event"
1916
+     },
1917
+     "response": {
1918
+      "$ref": "Event"
1919
+     },
1920
+     "scopes": [
1921
+      "https://www.googleapis.com/auth/calendar"
1922
+     ]
1923
+    },
1924
+    "insert": {
1925
+     "id": "calendar.events.insert",
1926
+     "path": "calendars/{calendarId}/events",
1927
+     "httpMethod": "POST",
1928
+     "description": "Creates an event.",
1929
+     "parameters": {
1930
+      "calendarId": {
1931
+       "type": "string",
1932
+       "description": "Calendar identifier.",
1933
+       "required": true,
1934
+       "location": "path"
1935
+      },
1936
+      "maxAttendees": {
1937
+       "type": "integer",
1938
+       "description": "The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional.",
1939
+       "format": "int32",
1940
+       "minimum": "1",
1941
+       "location": "query"
1942
+      },
1943
+      "sendNotifications": {
1944
+       "type": "boolean",
1945
+       "description": "Whether to send notifications about the creation of the new event. Optional. The default is False.",
1946
+       "location": "query"
1947
+      }
1948
+     },
1949
+     "parameterOrder": [
1950
+      "calendarId"
1951
+     ],
1952
+     "request": {
1953
+      "$ref": "Event"
1954
+     },
1955
+     "response": {
1956
+      "$ref": "Event"
1957
+     },
1958
+     "scopes": [
1959
+      "https://www.googleapis.com/auth/calendar"
1960
+     ]
1961
+    },
1962
+    "instances": {
1963
+     "id": "calendar.events.instances",
1964
+     "path": "calendars/{calendarId}/events/{eventId}/instances",
1965
+     "httpMethod": "GET",
1966
+     "description": "Returns instances of the specified recurring event.",
1967
+     "parameters": {
1968
+      "alwaysIncludeEmail": {
1969
+       "type": "boolean",
1970
+       "description": "Whether to always include a value in the email field for the organizer, creator and attendees, even if no real email is available (i.e. a generated, non-working value will be provided). The use of this option is discouraged and should only be used by clients which cannot handle the absence of an email address value in the mentioned places. Optional. The default is False.",
1971
+       "location": "query"
1972
+      },
1973
+      "calendarId": {
1974
+       "type": "string",
1975
+       "description": "Calendar identifier.",
1976
+       "required": true,
1977
+       "location": "path"
1978
+      },
1979
+      "eventId": {
1980
+       "type": "string",
1981
+       "description": "Recurring event identifier.",
1982
+       "required": true,
1983
+       "location": "path"
1984
+      },
1985
+      "maxAttendees": {
1986
+       "type": "integer",
1987
+       "description": "The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional.",
1988
+       "format": "int32",
1989
+       "minimum": "1",
1990
+       "location": "query"
1991
+      },
1992
+      "maxResults": {
1993
+       "type": "integer",
1994
+       "description": "Maximum number of events returned on one result page. By default the value is 250 events. The page size can never be larger than 2500 events. Optional.",
1995
+       "format": "int32",
1996
+       "minimum": "1",
1997
+       "location": "query"
1998
+      },
1999
+      "originalStart": {
2000
+       "type": "string",
2001
+       "description": "The original start time of the instance in the result. Optional.",
2002
+       "location": "query"
2003
+      },
2004
+      "pageToken": {
2005
+       "type": "string",
2006
+       "description": "Token specifying which result page to return. Optional.",
2007
+       "location": "query"
2008
+      },
2009
+      "showDeleted": {
2010
+       "type": "boolean",
2011
+       "description": "Whether to include deleted events (with status equals \"cancelled\") in the result. Cancelled instances of recurring events will still be included if singleEvents is False. Optional. The default is False.",
2012
+       "location": "query"
2013
+      },
2014
+      "timeMax": {
2015
+       "type": "string",
2016
+       "description": "Upper bound (exclusive) for an event's start time to filter by. Optional. The default is not to filter by start time.",
2017
+       "format": "date-time",
2018
+       "location": "query"
2019
+      },
2020
+      "timeMin": {
2021
+       "type": "string",
2022
+       "description": "Lower bound (inclusive) for an event's end time to filter by. Optional. The default is not to filter by end time.",
2023
+       "format": "date-time",
2024
+       "location": "query"
2025
+      },
2026
+      "timeZone": {
2027
+       "type": "string",
2028
+       "description": "Time zone used in the response. Optional. The default is the time zone of the calendar.",
2029
+       "location": "query"
2030
+      }
2031
+     },
2032
+     "parameterOrder": [
2033
+      "calendarId",
2034
+      "eventId"
2035
+     ],
2036
+     "response": {
2037
+      "$ref": "Events"
2038
+     },
2039
+     "scopes": [
2040
+      "https://www.googleapis.com/auth/calendar",
2041
+      "https://www.googleapis.com/auth/calendar.readonly"
2042
+     ],
2043
+     "supportsSubscription": true
2044
+    },
2045
+    "list": {
2046
+     "id": "calendar.events.list",
2047
+     "path": "calendars/{calendarId}/events",
2048
+     "httpMethod": "GET",
2049
+     "description": "Returns events on the specified calendar.",
2050
+     "parameters": {
2051
+      "alwaysIncludeEmail": {
2052
+       "type": "boolean",
2053
+       "description": "Whether to always include a value in the email field for the organizer, creator and attendees, even if no real email is available (i.e. a generated, non-working value will be provided). The use of this option is discouraged and should only be used by clients which cannot handle the absence of an email address value in the mentioned places. Optional. The default is False.",
2054
+       "location": "query"
2055
+      },
2056
+      "calendarId": {
2057
+       "type": "string",
2058
+       "description": "Calendar identifier.",
2059
+       "required": true,
2060
+       "location": "path"
2061
+      },
2062
+      "iCalUID": {
2063
+       "type": "string",
2064
+       "description": "Specifies event ID in the iCalendar format to be included in the response. Optional.",
2065
+       "location": "query"
2066
+      },
2067
+      "maxAttendees": {
2068
+       "type": "integer",
2069
+       "description": "The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional.",
2070
+       "format": "int32",
2071
+       "minimum": "1",
2072
+       "location": "query"
2073
+      },
2074
+      "maxResults": {
2075
+       "type": "integer",
2076
+       "description": "Maximum number of events returned on one result page. By default the value is 250 events. The page size can never be larger than 2500 events. Optional.",
2077
+       "format": "int32",
2078
+       "minimum": "1",
2079
+       "location": "query"
2080
+      },
2081
+      "orderBy": {
2082
+       "type": "string",
2083
+       "description": "The order of the events returned in the result. Optional. The default is an unspecified, stable order.",
2084
+       "enum": [
2085
+        "startTime",
2086
+        "updated"
2087
+       ],
2088
+       "enumDescriptions": [
2089
+        "Order by the start date/time (ascending). This is only available when querying single events (i.e. the parameter singleEvents is True)",
2090
+        "Order by last modification time (ascending)."
2091
+       ],
2092
+       "location": "query"
2093
+      },
2094
+      "pageToken": {
2095
+       "type": "string",
2096
+       "description": "Token specifying which result page to return. Optional.",
2097
+       "location": "query"
2098
+      },
2099
+      "privateExtendedProperty": {
2100
+       "type": "string",
2101
+       "description": "Extended properties constraint specified as propertyName=value. Matches only private properties. This parameter might be repeated multiple times to return events that match all given constraints.",
2102
+       "repeated": true,
2103
+       "location": "query"
2104
+      },
2105
+      "q": {
2106
+       "type": "string",
2107
+       "description": "Free text search terms to find events that match these terms in any field, except for extended properties. Optional.",
2108
+       "location": "query"
2109
+      },
2110
+      "sharedExtendedProperty": {
2111
+       "type": "string",
2112
+       "description": "Extended properties constraint specified as propertyName=value. Matches only shared properties. This parameter might be repeated multiple times to return events that match all given constraints.",
2113
+       "repeated": true,
2114
+       "location": "query"
2115
+      },
2116
+      "showDeleted": {
2117
+       "type": "boolean",
2118
+       "description": "Whether to include deleted events (with status equals \"cancelled\") in the result. Cancelled instances of recurring events (but not the underlying recurring event) will still be included if showDeleted and singleEvents are both False. If showDeleted and singleEvents are both True, only single instances of deleted events (but not the underlying recurring events) are returned. Optional. The default is False.",
2119
+       "location": "query"
2120
+      },
2121
+      "showHiddenInvitations": {
2122
+       "type": "boolean",
2123
+       "description": "Whether to include hidden invitations in the result. Optional. The default is False.",
2124
+       "location": "query"
2125
+      },
2126
+      "singleEvents": {
2127
+       "type": "boolean",
2128
+       "description": "Whether to expand recurring events into instances and only return single one-off events and instances of recurring events, but not the underlying recurring events themselves. Optional. The default is False.",
2129
+       "location": "query"
2130
+      },
2131
+      "syncToken": {
2132
+       "type": "string",
2133
+       "description": "Token obtained from the nextSyncToken field returned on the last page of results from the previous list request. It makes the result of this list request contain only entries that have changed since then. All events deleted since the previous list request will always be in the result set and it is not allowed to set showDeleted to False.\nThere are several query parameters that cannot be specified together with nextSyncToken to ensure consistency of the client state.\n\nThese are: \n- iCalUID \n- orderBy \n- privateExtendedProperty \n- q \n- sharedExtendedProperty \n- timeMin \n- timeMax \n- updatedMin If the syncToken expires, the server will respond with a 410 GONE response code and the client should clear its storage and perform a full synchronization without any syncToken.\nLearn more about incremental synchronization.\nOptional. The default is to return all entries.",
2134
+       "location": "query"
2135
+      },
2136
+      "timeMax": {
2137
+       "type": "string",
2138
+       "description": "Upper bound (exclusive) for an event's start time to filter by. Optional. The default is not to filter by start time.",
2139
+       "format": "date-time",
2140
+       "location": "query"
2141
+      },
2142
+      "timeMin": {
2143
+       "type": "string",
2144
+       "description": "Lower bound (inclusive) for an event's end time to filter by. Optional. The default is not to filter by end time.",
2145
+       "format": "date-time",
2146
+       "location": "query"
2147
+      },
2148
+      "timeZone": {
2149
+       "type": "string",
2150
+       "description": "Time zone used in the response. Optional. The default is the time zone of the calendar.",
2151
+       "location": "query"
2152
+      },
2153
+      "updatedMin": {
2154
+       "type": "string",
2155
+       "description": "Lower bound for an event's last modification time (as a RFC 3339 timestamp) to filter by. When specified, entries deleted since this time will always be included regardless of showDeleted. Optional. The default is not to filter by last modification time.",
2156
+       "format": "date-time",
2157
+       "location": "query"
2158
+      }
2159
+     },
2160
+     "parameterOrder": [
2161
+      "calendarId"
2162
+     ],
2163
+     "response": {
2164
+      "$ref": "Events"
2165
+     },
2166
+     "scopes": [
2167
+      "https://www.googleapis.com/auth/calendar",
2168
+      "https://www.googleapis.com/auth/calendar.readonly"
2169
+     ],
2170
+     "supportsSubscription": true
2171
+    },
2172
+    "move": {
2173
+     "id": "calendar.events.move",
2174
+     "path": "calendars/{calendarId}/events/{eventId}/move",
2175
+     "httpMethod": "POST",
2176
+     "description": "Moves an event to another calendar, i.e. changes an event's organizer.",
2177
+     "parameters": {
2178
+      "calendarId": {
2179
+       "type": "string",
2180
+       "description": "Calendar identifier of the source calendar where the event currently is on.",
2181
+       "required": true,
2182
+       "location": "path"
2183
+      },
2184
+      "destination": {
2185
+       "type": "string",
2186
+       "description": "Calendar identifier of the target calendar where the event is to be moved to.",
2187
+       "required": true,
2188
+       "location": "query"
2189
+      },
2190
+      "eventId": {
2191
+       "type": "string",
2192
+       "description": "Event identifier.",
2193
+       "required": true,
2194
+       "location": "path"
2195
+      },
2196
+      "sendNotifications": {
2197
+       "type": "boolean",
2198
+       "description": "Whether to send notifications about the change of the event's organizer. Optional. The default is False.",
2199
+       "location": "query"
2200
+      }
2201
+     },
2202
+     "parameterOrder": [
2203
+      "calendarId",
2204
+      "eventId",
2205
+      "destination"
2206
+     ],
2207
+     "response": {
2208
+      "$ref": "Event"
2209
+     },
2210
+     "scopes": [
2211
+      "https://www.googleapis.com/auth/calendar"
2212
+     ]
2213
+    },
2214
+    "patch": {
2215
+     "id": "calendar.events.patch",
2216
+     "path": "calendars/{calendarId}/events/{eventId}",
2217
+     "httpMethod": "PATCH",
2218
+     "description": "Updates an event. This method supports patch semantics.",
2219
+     "parameters": {
2220
+      "alwaysIncludeEmail": {
2221
+       "type": "boolean",
2222
+       "description": "Whether to always include a value in the email field for the organizer, creator and attendees, even if no real email is available (i.e. a generated, non-working value will be provided). The use of this option is discouraged and should only be used by clients which cannot handle the absence of an email address value in the mentioned places. Optional. The default is False.",
2223
+       "location": "query"
2224
+      },
2225
+      "calendarId": {
2226
+       "type": "string",
2227
+       "description": "Calendar identifier.",
2228
+       "required": true,
2229
+       "location": "path"
2230
+      },
2231
+      "eventId": {
2232
+       "type": "string",
2233
+       "description": "Event identifier.",
2234
+       "required": true,
2235
+       "location": "path"
2236
+      },
2237
+      "maxAttendees": {
2238
+       "type": "integer",
2239
+       "description": "The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional.",
2240
+       "format": "int32",
2241
+       "minimum": "1",
2242
+       "location": "query"
2243
+      },
2244
+      "sendNotifications": {
2245
+       "type": "boolean",
2246
+       "description": "Whether to send notifications about the event update (e.g. attendee's responses, title changes, etc.). Optional. The default is False.",
2247
+       "location": "query"
2248
+      }
2249
+     },
2250
+     "parameterOrder": [
2251
+      "calendarId",
2252
+      "eventId"
2253
+     ],
2254
+     "request": {
2255
+      "$ref": "Event"
2256
+     },
2257
+     "response": {
2258
+      "$ref": "Event"
2259
+     },
2260
+     "scopes": [
2261
+      "https://www.googleapis.com/auth/calendar"
2262
+     ]
2263
+    },
2264
+    "quickAdd": {
2265
+     "id": "calendar.events.quickAdd",
2266
+     "path": "calendars/{calendarId}/events/quickAdd",
2267
+     "httpMethod": "POST",
2268
+     "description": "Creates an event based on a simple text string.",
2269
+     "parameters": {
2270
+      "calendarId": {
2271
+       "type": "string",
2272
+       "description": "Calendar identifier.",
2273
+       "required": true,
2274
+       "location": "path"
2275
+      },
2276
+      "sendNotifications": {
2277
+       "type": "boolean",
2278
+       "description": "Whether to send notifications about the creation of the event. Optional. The default is False.",
2279
+       "location": "query"
2280
+      },
2281
+      "text": {
2282
+       "type": "string",
2283
+       "description": "The text describing the event to be created.",
2284
+       "required": true,
2285
+       "location": "query"
2286
+      }
2287
+     },
2288
+     "parameterOrder": [
2289
+      "calendarId",
2290
+      "text"
2291
+     ],
2292
+     "response": {
2293
+      "$ref": "Event"
2294
+     },
2295
+     "scopes": [
2296
+      "https://www.googleapis.com/auth/calendar"
2297
+     ]
2298
+    },
2299
+    "update": {
2300
+     "id": "calendar.events.update",
2301
+     "path": "calendars/{calendarId}/events/{eventId}",
2302
+     "httpMethod": "PUT",
2303
+     "description": "Updates an event.",
2304
+     "parameters": {
2305
+      "alwaysIncludeEmail": {
2306
+       "type": "boolean",
2307
+       "description": "Whether to always include a value in the email field for the organizer, creator and attendees, even if no real email is available (i.e. a generated, non-working value will be provided). The use of this option is discouraged and should only be used by clients which cannot handle the absence of an email address value in the mentioned places. Optional. The default is False.",
2308
+       "location": "query"
2309
+      },
2310
+      "calendarId": {
2311
+       "type": "string",
2312
+       "description": "Calendar identifier.",
2313
+       "required": true,
2314
+       "location": "path"
2315
+      },
2316
+      "eventId": {
2317
+       "type": "string",
2318
+       "description": "Event identifier.",
2319
+       "required": true,
2320
+       "location": "path"
2321
+      },
2322
+      "maxAttendees": {
2323
+       "type": "integer",
2324
+       "description": "The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional.",
2325
+       "format": "int32",
2326
+       "minimum": "1",
2327
+       "location": "query"
2328
+      },
2329
+      "sendNotifications": {
2330
+       "type": "boolean",
2331
+       "description": "Whether to send notifications about the event update (e.g. attendee's responses, title changes, etc.). Optional. The default is False.",
2332
+       "location": "query"
2333
+      }
2334
+     },
2335
+     "parameterOrder": [
2336
+      "calendarId",
2337
+      "eventId"
2338
+     ],
2339
+     "request": {
2340
+      "$ref": "Event"
2341
+     },
2342
+     "response": {
2343
+      "$ref": "Event"
2344
+     },
2345
+     "scopes": [
2346
+      "https://www.googleapis.com/auth/calendar"
2347
+     ]
2348
+    },
2349
+    "watch": {
2350
+     "id": "calendar.events.watch",
2351
+     "path": "calendars/{calendarId}/events/watch",
2352
+     "httpMethod": "POST",
2353
+     "description": "Watch for changes to Events resources.",
2354
+     "parameters": {
2355
+      "alwaysIncludeEmail": {
2356
+       "type": "boolean",
2357
+       "description": "Whether to always include a value in the email field for the organizer, creator and attendees, even if no real email is available (i.e. a generated, non-working value will be provided). The use of this option is discouraged and should only be used by clients which cannot handle the absence of an email address value in the mentioned places. Optional. The default is False.",
2358
+       "location": "query"
2359
+      },
2360
+      "calendarId": {
2361
+       "type": "string",
2362
+       "description": "Calendar identifier.",
2363
+       "required": true,
2364
+       "location": "path"
2365
+      },
2366
+      "iCalUID": {
2367
+       "type": "string",
2368
+       "description": "Specifies event ID in the iCalendar format to be included in the response. Optional.",
2369
+       "location": "query"
2370
+      },
2371
+      "maxAttendees": {
2372
+       "type": "integer",
2373
+       "description": "The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional.",
2374
+       "format": "int32",
2375
+       "minimum": "1",
2376
+       "location": "query"
2377
+      },
2378
+      "maxResults": {
2379
+       "type": "integer",
2380
+       "description": "Maximum number of events returned on one result page. By default the value is 250 events. The page size can never be larger than 2500 events. Optional.",
2381
+       "format": "int32",
2382
+       "minimum": "1",
2383
+       "location": "query"
2384
+      },
2385
+      "orderBy": {
2386
+       "type": "string",
2387
+       "description": "The order of the events returned in the result. Optional. The default is an unspecified, stable order.",
2388
+       "enum": [
2389
+        "startTime",
2390
+        "updated"
2391
+       ],
2392
+       "enumDescriptions": [
2393
+        "Order by the start date/time (ascending). This is only available when querying single events (i.e. the parameter singleEvents is True)",
2394
+        "Order by last modification time (ascending)."
2395
+       ],
2396
+       "location": "query"
2397
+      },
2398
+      "pageToken": {
2399
+       "type": "string",
2400
+       "description": "Token specifying which result page to return. Optional.",
2401
+       "location": "query"
2402
+      },
2403
+      "privateExtendedProperty": {
2404
+       "type": "string",
2405
+       "description": "Extended properties constraint specified as propertyName=value. Matches only private properties. This parameter might be repeated multiple times to return events that match all given constraints.",
2406
+       "repeated": true,
2407
+       "location": "query"
2408
+      },
2409
+      "q": {
2410
+       "type": "string",
2411
+       "description": "Free text search terms to find events that match these terms in any field, except for extended properties. Optional.",
2412
+       "location": "query"
2413
+      },
2414
+      "sharedExtendedProperty": {
2415
+       "type": "string",
2416
+       "description": "Extended properties constraint specified as propertyName=value. Matches only shared properties. This parameter might be repeated multiple times to return events that match all given constraints.",
2417
+       "repeated": true,
2418
+       "location": "query"
2419
+      },
2420
+      "showDeleted": {
2421
+       "type": "boolean",
2422
+       "description": "Whether to include deleted events (with status equals \"cancelled\") in the result. Cancelled instances of recurring events (but not the underlying recurring event) will still be included if showDeleted and singleEvents are both False. If showDeleted and singleEvents are both True, only single instances of deleted events (but not the underlying recurring events) are returned. Optional. The default is False.",
2423
+       "location": "query"
2424
+      },
2425
+      "showHiddenInvitations": {
2426
+       "type": "boolean",
2427
+       "description": "Whether to include hidden invitations in the result. Optional. The default is False.",
2428
+       "location": "query"
2429
+      },
2430
+      "singleEvents": {
2431
+       "type": "boolean",
2432
+       "description": "Whether to expand recurring events into instances and only return single one-off events and instances of recurring events, but not the underlying recurring events themselves. Optional. The default is False.",
2433
+       "location": "query"
2434
+      },
2435
+      "syncToken": {
2436
+       "type": "string",
2437
+       "description": "Token obtained from the nextSyncToken field returned on the last page of results from the previous list request. It makes the result of this list request contain only entries that have changed since then. All events deleted since the previous list request will always be in the result set and it is not allowed to set showDeleted to False.\nThere are several query parameters that cannot be specified together with nextSyncToken to ensure consistency of the client state.\n\nThese are: \n- iCalUID \n- orderBy \n- privateExtendedProperty \n- q \n- sharedExtendedProperty \n- timeMin \n- timeMax \n- updatedMin If the syncToken expires, the server will respond with a 410 GONE response code and the client should clear its storage and perform a full synchronization without any syncToken.\nLearn more about incremental synchronization.\nOptional. The default is to return all entries.",
2438
+       "location": "query"
2439
+      },
2440
+      "timeMax": {
2441
+       "type": "string",
2442
+       "description": "Upper bound (exclusive) for an event's start time to filter by. Optional. The default is not to filter by start time.",
2443
+       "format": "date-time",
2444
+       "location": "query"
2445
+      },
2446
+      "timeMin": {
2447
+       "type": "string",
2448
+       "description": "Lower bound (inclusive) for an event's end time to filter by. Optional. The default is not to filter by end time.",
2449
+       "format": "date-time",
2450
+       "location": "query"
2451
+      },
2452
+      "timeZone": {
2453
+       "type": "string",
2454
+       "description": "Time zone used in the response. Optional. The default is the time zone of the calendar.",
2455
+       "location": "query"
2456
+      },
2457
+      "updatedMin": {
2458
+       "type": "string",
2459
+       "description": "Lower bound for an event's last modification time (as a RFC 3339 timestamp) to filter by. When specified, entries deleted since this time will always be included regardless of showDeleted. Optional. The default is not to filter by last modification time.",
2460
+       "format": "date-time",
2461
+       "location": "query"
2462
+      }
2463
+     },
2464
+     "parameterOrder": [
2465
+      "calendarId"
2466
+     ],
2467
+     "request": {
2468
+      "$ref": "Channel",
2469
+      "parameterName": "resource"
2470
+     },
2471
+     "response": {
2472
+      "$ref": "Channel"
2473
+     },
2474
+     "scopes": [
2475
+      "https://www.googleapis.com/auth/calendar",
2476
+      "https://www.googleapis.com/auth/calendar.readonly"
2477
+     ],
2478
+     "supportsSubscription": true
2479
+    }
2480
+   }
2481
+  },
2482
+  "freebusy": {
2483
+   "methods": {
2484
+    "query": {
2485
+     "id": "calendar.freebusy.query",
2486
+     "path": "freeBusy",
2487
+     "httpMethod": "POST",
2488
+     "description": "Returns free/busy information for a set of calendars.",
2489
+     "request": {
2490
+      "$ref": "FreeBusyRequest"
2491
+     },
2492
+     "response": {
2493
+      "$ref": "FreeBusyResponse"
2494
+     },
2495
+     "scopes": [
2496
+      "https://www.googleapis.com/auth/calendar",
2497
+      "https://www.googleapis.com/auth/calendar.readonly"
2498
+     ]
2499
+    }
2500
+   }
2501
+  },
2502
+  "settings": {
2503
+   "methods": {
2504
+    "get": {
2505
+     "id": "calendar.settings.get",
2506
+     "path": "users/me/settings/{setting}",
2507
+     "httpMethod": "GET",
2508
+     "description": "Returns a single user setting.",
2509
+     "parameters": {
2510
+      "setting": {
2511
+       "type": "string",
2512
+       "description": "The id of the user setting.",
2513
+       "required": true,
2514
+       "location": "path"
2515
+      }
2516
+     },
2517
+     "parameterOrder": [
2518
+      "setting"
2519
+     ],
2520
+     "response": {
2521
+      "$ref": "Setting"
2522
+     },
2523
+     "scopes": [
2524
+      "https://www.googleapis.com/auth/calendar",
2525
+      "https://www.googleapis.com/auth/calendar.readonly"
2526
+     ]
2527
+    },
2528
+    "list": {
2529
+     "id": "calendar.settings.list",
2530
+     "path": "users/me/settings",
2531
+     "httpMethod": "GET",
2532
+     "description": "Returns all user settings for the authenticated user.",
2533
+     "parameters": {
2534
+      "maxResults": {
2535
+       "type": "integer",
2536
+       "description": "Maximum number of entries returned on one result page. By default the value is 100 entries. The page size can never be larger than 250 entries. Optional.",
2537
+       "format": "int32",
2538
+       "minimum": "1",
2539
+       "location": "query"
2540
+      },
2541
+      "pageToken": {
2542
+       "type": "string",
2543
+       "description": "Token specifying which result page to return. Optional.",
2544
+       "location": "query"
2545
+      },
2546
+      "syncToken": {
2547
+       "type": "string",
2548
+       "description": "Token obtained from the nextSyncToken field returned on the last page of results from the previous list request. It makes the result of this list request contain only entries that have changed since then.\nIf the syncToken expires, the server will respond with a 410 GONE response code and the client should clear its storage and perform a full synchronization without any syncToken.\nLearn more about incremental synchronization.\nOptional. The default is to return all entries.",
2549
+       "location": "query"
2550
+      }
2551
+     },
2552
+     "response": {
2553
+      "$ref": "Settings"
2554
+     },
2555
+     "scopes": [
2556
+      "https://www.googleapis.com/auth/calendar",
2557
+      "https://www.googleapis.com/auth/calendar.readonly"
2558
+     ],
2559
+     "supportsSubscription": true
2560
+    },
2561
+    "watch": {
2562
+     "id": "calendar.settings.watch",
2563
+     "path": "users/me/settings/watch",
2564
+     "httpMethod": "POST",
2565
+     "description": "Watch for changes to Settings resources.",
2566
+     "parameters": {
2567
+      "maxResults": {
2568
+       "type": "integer",
2569
+       "description": "Maximum number of entries returned on one result page. By default the value is 100 entries. The page size can never be larger than 250 entries. Optional.",
2570
+       "format": "int32",
2571
+       "minimum": "1",
2572
+       "location": "query"
2573
+      },
2574
+      "pageToken": {
2575
+       "type": "string",
2576
+       "description": "Token specifying which result page to return. Optional.",
2577
+       "location": "query"
2578
+      },
2579
+      "syncToken": {
2580
+       "type": "string",
2581
+       "description": "Token obtained from the nextSyncToken field returned on the last page of results from the previous list request. It makes the result of this list request contain only entries that have changed since then.\nIf the syncToken expires, the server will respond with a 410 GONE response code and the client should clear its storage and perform a full synchronization without any syncToken.\nLearn more about incremental synchronization.\nOptional. The default is to return all entries.",
2582
+       "location": "query"
2583
+      }
2584
+     },
2585
+     "request": {
2586
+      "$ref": "Channel",
2587
+      "parameterName": "resource"
2588
+     },
2589
+     "response": {
2590
+      "$ref": "Channel"
2591
+     },
2592
+     "scopes": [
2593
+      "https://www.googleapis.com/auth/calendar",
2594
+      "https://www.googleapis.com/auth/calendar.readonly"
2595
+     ],
2596
+     "supportsSubscription": true
2597
+    }
2598
+   }
2599
+  }
2600
+ }
2601
+}

BIN
spec/data_fixtures/private.key


+ 4 - 4
spec/fixtures/agents.yml

@@ -10,8 +10,8 @@ jane_website_agent:
10 10
                  :expected_update_period_in_days => 2,
11 11
                  :mode => :on_change,
12 12
                  :extract => {
13
-                     :title => {:css => "item title", :text => true},
14
-                     :url => {:css => "item link", :text => true}
13
+                     :title => {:css => "item title", :value => './/text()'},
14
+                     :url => {:css => "item link", :value => './/text()'}
15 15
                  }
16 16
                }.to_json.inspect %>
17 17
 
@@ -27,8 +27,8 @@ bob_website_agent:
27 27
                  :expected_update_period_in_days => 2,
28 28
                  :mode => :on_change,
29 29
                  :extract => {
30
-                   :url => {:css => "#comic img", :attr => "src"},
31
-                   :title => {:css => "#comic img", :attr => "title"}
30
+                   :url => {:css => "#comic img", :value => "@src"},
31
+                   :title => {:css => "#comic img", :value => "@title"}
32 32
                  }
33 33
                }.to_json.inspect %>
34 34
 

+ 68 - 15
spec/helpers/dot_helper_spec.rb

@@ -1,12 +1,6 @@
1 1
 require 'spec_helper'
2 2
 
3 3
 describe DotHelper do
4
-  describe "#dot_id" do
5
-    it "properly escapes double quotaion and backslash" do
6
-      dot_id('hello\\"').should == '"hello\\\\\\""'
7
-    end
8
-  end
9
-
10 4
   describe "with example Agents" do
11 5
     class Agents::DotFoo < Agent
12 6
       default_schedule "2pm"
@@ -30,18 +24,77 @@ describe DotHelper do
30 24
     end
31 25
 
32 26
     describe "#agents_dot" do
27
+      before do
28
+        @agents = [
29
+          @foo = Agents::DotFoo.new(name: "foo").tap { |agent|
30
+            agent.user = users(:bob)
31
+            agent.save!
32
+          },
33
+
34
+          @bar1 = Agents::DotBar.new(name: "bar1").tap { |agent|
35
+            agent.user = users(:bob)
36
+            agent.sources << @foo
37
+            agent.save!
38
+          },
39
+
40
+          @bar2 = Agents::DotBar.new(name: "bar2").tap { |agent|
41
+            agent.user = users(:bob)
42
+            agent.sources << @foo
43
+            agent.propagate_immediately = true
44
+            agent.disabled = true
45
+            agent.save!
46
+          },
47
+
48
+          @bar3 = Agents::DotBar.new(name: "bar3").tap { |agent|
49
+            agent.user = users(:bob)
50
+            agent.sources << @bar2
51
+            agent.save!
52
+          },
53
+        ]
54
+      end
55
+
33 56
       it "generates a DOT script" do
34
-        @foo = Agents::DotFoo.new(:name => "foo")
35
-        @foo.user = users(:bob)
36
-        @foo.save!
57
+        agents_dot(@agents).should =~ %r{
58
+          \A
59
+          digraph \s foo \{
60
+            node \[ [^\]]+ \];
61
+            (?<foo>\w+) \[label=foo\];
62
+            \k<foo> -> (?<bar1>\w+) \[style=dashed\];
63
+            \k<foo> -> (?<bar2>\w+) \[color="\#999999"\];
64
+            \k<bar1> \[label=bar1\];
65
+            \k<bar2> \[label="bar2 \s \(Disabled\)",style="rounded,dashed",color="\#999999",fontcolor="\#999999"\];
66
+            \k<bar2> -> (?<bar3>\w+) \[style=dashed,color="\#999999"\];
67
+            \k<bar3> \[label=bar3\];
68
+          \}
69
+          \z
70
+        }x
71
+      end
37 72
 
38
-        @bar = Agents::DotBar.new(:name => "bar")
39
-        @bar.user = users(:bob)
40
-        @bar.sources << @foo
41
-        @bar.save!
73
+      it "generates a richer DOT script" do
74
+        agents_dot(@agents, true).should =~ %r{
75
+          \A
76
+          digraph \s foo \{
77
+            node \[ [^\]]+ \];
78
+            (?<foo>\w+) \[label=foo,URL="#{Regexp.quote(agent_path(@foo))}"\];
79
+            \k<foo> -> (?<bar1>\w+) \[style=dashed\];
80
+            \k<foo> -> (?<bar2>\w+) \[color="\#999999"\];
81
+            \k<bar1> \[label=bar1,URL="#{Regexp.quote(agent_path(@bar1))}"\];
82
+            \k<bar2> \[label="bar2 \s \(Disabled\)",URL="#{Regexp.quote(agent_path(@bar2))}",style="rounded,dashed",color="\#999999",fontcolor="\#999999"\];
83
+            \k<bar2> -> (?<bar3>\w+) \[style=dashed,color="\#999999"\];
84
+            \k<bar3> \[label=bar3,URL="#{Regexp.quote(agent_path(@bar3))}"\];
85
+          \}
86
+          \z
87
+        }x
88
+      end
89
+    end
90
+  end
42 91
 
43
-        agents_dot([@foo, @bar]).should == 'digraph foo {"foo";"foo"->"bar";"bar";}'
44
-        agents_dot([@foo, @bar], true).should == 'digraph foo {"foo"[URL="/agents/%d"];"foo"->"bar";"bar"[URL="/agents/%d"];}' % [@foo.id, @bar.id]
92
+  describe DotHelper::DotDrawer do
93
+    describe "#id" do
94
+      it "properly escapes double quotaion and backslash" do
95
+        DotHelper::DotDrawer.draw(foo: "") {
96
+          id('hello\\"')
97
+        }.should == '"hello\\\\\\""'
45 98
       end
46 99
     end
47 100
   end

+ 3 - 3
spec/lib/utils_spec.rb

@@ -22,8 +22,8 @@ describe Utils do
22 22
 
23 23
       Utils.unindent("Hello\n  I am indented").should == "Hello\n  I am indented"
24 24
 
25
-      a = "        Events will have the fields you specified.  Your options look like:\n\n            {\n      \"url\": {\n        \"css\": \"#comic img\",\n        \"attr\": \"src\"\n      },\n      \"title\": {\n        \"css\": \"#comic img\",\n        \"attr\": \"title\"\n      }\n    }\"\n"
26
-      Utils.unindent(a).should == "Events will have the fields you specified.  Your options look like:\n\n    {\n      \"url\": {\n\"css\": \"#comic img\",\n\"attr\": \"src\"\n      },\n      \"title\": {\n\"css\": \"#comic img\",\n\"attr\": \"title\"\n      }\n    }\""
25
+      a = "        Events will have the fields you specified.  Your options look like:\n\n            {\n      \"url\": {\n        \"css\": \"#comic img\",\n        \"value\": \"@src\"\n      },\n      \"title\": {\n        \"css\": \"#comic img\",\n        \"value\": \"@title\"\n      }\n    }\"\n"
26
+      Utils.unindent(a).should == "Events will have the fields you specified.  Your options look like:\n\n    {\n      \"url\": {\n\"css\": \"#comic img\",\n\"value\": \"@src\"\n      },\n      \"title\": {\n\"css\": \"#comic img\",\n\"value\": \"@title\"\n      }\n    }\""
27 27
     end
28 28
   end
29 29
 
@@ -114,4 +114,4 @@ describe Utils do
114 114
       cleaned_json.should include("<\\/script>")
115 115
     end
116 116
   end
117
-end
117
+end

+ 102 - 0
spec/models/agent_spec.rb

@@ -132,6 +132,13 @@ describe Agent do
132 132
       it_behaves_like HasGuid
133 133
     end
134 134
 
135
+    describe ".short_type" do
136
+      it "returns a short name without 'Agents::'" do
137
+        Agents::SomethingSource.new.short_type.should == "SomethingSource"
138
+        Agents::CannotBeScheduled.new.short_type.should == "CannotBeScheduled"
139
+      end
140
+    end
141
+
135 142
     describe ".default_schedule" do
136 143
       it "stores the default on the class" do
137 144
         Agents::SomethingSource.default_schedule.should == "2pm"
@@ -729,3 +736,98 @@ describe Agent do
729 736
     end
730 737
   end
731 738
 end
739
+
740
+describe AgentDrop do
741
+  def interpolate(string, agent)
742
+    agent.interpolate_string(string, "agent" => agent)
743
+  end
744
+
745
+  before do
746
+    @wsa1 = Agents::WebsiteAgent.new(
747
+      name: 'XKCD',
748
+      options: {
749
+        expected_update_period_in_days: 2,
750
+        type: 'html',
751
+        url: 'http://xkcd.com/',
752
+        mode: 'on_change',
753
+        extract: {
754
+          url: { css: '#comic img', value: '@src' },
755
+          title: { css: '#comic img', value: '@alt' },
756
+        },
757
+      },
758
+      schedule: 'every_1h',
759
+      keep_events_for: 2)
760
+    @wsa1.user = users(:bob)
761
+    @wsa1.save!
762
+
763
+    @wsa2 = Agents::WebsiteAgent.new(
764
+      name: 'Dilbert',
765
+      options: {
766
+        expected_update_period_in_days: 2,
767
+        type: 'html',
768
+        url: 'http://dilbert.com/',
769
+        mode: 'on_change',
770
+        extract: {
771
+          url: { css: '[id^=strip_enlarged_] img', value: '@src' },
772
+          title: { css: '.STR_DateStrip', value: './/text()' },
773
+        },
774
+      },
775
+      schedule: 'every_12h',
776
+      keep_events_for: 2)
777
+    @wsa2.user = users(:bob)
778
+    @wsa2.save!
779
+
780
+    @efa = Agents::EventFormattingAgent.new(
781
+      name: 'Formatter',
782
+      options: {
783
+        instructions: {
784
+          message: '{{agent.name}}: {{title}} {{url}}',
785
+          agent: '{{agent.type}}',
786
+        },
787
+        mode: 'clean',
788
+        matchers: [],
789
+        skip_created_at: 'false',
790
+      },
791
+      keep_events_for: 2,
792
+      propagate_immediately: true)
793
+    @efa.user = users(:bob)
794
+    @efa.sources << @wsa1 << @wsa2
795
+    @efa.memory[:test] = 1
796
+    @efa.save!
797
+  end
798
+
799
+  it 'should be created via Agent#to_liquid' do
800
+    @wsa1.to_liquid.class.should be(AgentDrop)
801
+    @wsa2.to_liquid.class.should be(AgentDrop)
802
+    @efa.to_liquid.class.should be(AgentDrop)
803
+  end
804
+
805
+  it 'should have .type and .name' do
806
+    t = '{{agent.type}}: {{agent.name}}'
807
+    interpolate(t, @wsa1).should eq('WebsiteAgent: XKCD')
808
+    interpolate(t, @wsa2).should eq('WebsiteAgent: Dilbert')
809
+    interpolate(t, @efa).should eq('EventFormattingAgent: Formatter')
810
+  end
811
+
812
+  it 'should have .options' do
813
+    t = '{{agent.options.url}}'
814
+    interpolate(t, @wsa1).should eq('http://xkcd.com/')
815
+    interpolate(t, @wsa2).should eq('http://dilbert.com/')
816
+    interpolate('{{agent.options.instructions.message}}',
817
+                @efa).should eq('{{agent.name}}: {{title}} {{url}}')
818
+  end
819
+
820
+  it 'should have .sources' do
821
+    t = '{{agent.sources.size}}: {{agent.sources | map:"name" | join:", "}}'
822
+    interpolate(t, @wsa1).should eq('0: ')
823
+    interpolate(t, @wsa2).should eq('0: ')
824
+    interpolate(t, @efa).should eq('2: XKCD, Dilbert')
825
+  end
826
+
827
+  it 'should have .receivers' do
828
+    t = '{{agent.receivers.size}}: {{agent.receivers | map:"name" | join:", "}}'
829
+    interpolate(t, @wsa1).should eq('1: Formatter')
830
+    interpolate(t, @wsa2).should eq('1: Formatter')
831
+    interpolate(t, @efa).should eq('0: ')
832
+  end
833
+end

+ 3 - 2
spec/models/agents/email_agent_spec.rb

@@ -1,12 +1,14 @@
1 1
 require 'spec_helper'
2 2
 
3 3
 describe Agents::EmailAgent do
4
+  it_behaves_like EmailConcern
5
+
4 6
   def get_message_part(mail, content_type)
5 7
     mail.body.parts.find { |p| p.content_type.match content_type }.body.raw_source
6 8
   end
7 9
 
8 10
   before do
9
-    @checker = Agents::EmailAgent.new(:name => "something", :options => { :expected_receive_period_in_days => 2, :subject => "something interesting" })
11
+    @checker = Agents::EmailAgent.new(:name => "something", :options => { :expected_receive_period_in_days => "2", :subject => "something interesting" })
10 12
     @checker.user = users(:bob)
11 13
     @checker.save!
12 14
   end
@@ -54,6 +56,5 @@ describe Agents::EmailAgent do
54 56
       plain_email_text.should =~ /avehumidity/
55 57
       html_email_text.should =~ /avehumidity/
56 58
     end
57
-
58 59
   end
59 60
 end

+ 3 - 1
spec/models/agents/email_digest_agent_spec.rb

@@ -1,12 +1,14 @@
1 1
 require 'spec_helper'
2 2
 
3 3
 describe Agents::EmailDigestAgent do
4
+  it_behaves_like EmailConcern
5
+
4 6
   def get_message_part(mail, content_type)
5 7
     mail.body.parts.find { |p| p.content_type.match content_type }.body.raw_source
6 8
   end
7 9
 
8 10
   before do
9
-    @checker = Agents::EmailDigestAgent.new(:name => "something", :options => { :expected_receive_period_in_days => 2, :subject => "something interesting" })
11
+    @checker = Agents::EmailDigestAgent.new(:name => "something", :options => { :expected_receive_period_in_days => "2", :subject => "something interesting" })
10 12
     @checker.user = users(:bob)
11 13
     @checker.save!
12 14
   end

+ 5 - 17
spec/models/agents/event_formatting_agent_spec.rb

@@ -7,7 +7,8 @@ describe Agents::EventFormattingAgent do
7 7
         :options => {
8 8
             :instructions => {
9 9
                 :message => "Received {{content.text}} from {{content.name}} .",
10
-                :subject => "Weather looks like {{conditions}} according to the forecast at {{pretty_date.time}}"
10
+                :subject => "Weather looks like {{conditions}} according to the forecast at {{pretty_date.time}}",
11
+                :agent => "{{agent.type}}",
11 12
             },
12 13
             :mode => "clean",
13 14
             :matchers => [
@@ -17,7 +18,6 @@ describe Agents::EventFormattingAgent do
17 18
                     :to => "pretty_date",
18 19
                 },
19 20
             ],
20
-            :skip_agent => "false",
21 21
             :skip_created_at => "false"
22 22
         }
23 23
     }
@@ -53,14 +53,6 @@ describe Agents::EventFormattingAgent do
53 53
       Event.last.payload[:content].should_not == nil
54 54
     end
55 55
 
56
-    it "should accept skip_agent" do
57
-      @checker.receive([@event])
58
-      Event.last.payload[:agent].should == "WeatherAgent"
59
-      @checker.options[:skip_agent] = "true"
60
-      @checker.receive([@event])
61
-      Event.last.payload[:agent].should == nil
62
-    end
63
-
64 56
     it "should accept skip_created_at" do
65 57
       @checker.receive([@event])
66 58
       Event.last.payload[:created_at].should_not == nil
@@ -69,12 +61,13 @@ describe Agents::EventFormattingAgent do
69 61
       Event.last.payload[:created_at].should == nil
70 62
     end
71 63
 
72
-    it "should handle JSONPaths in instructions" do
64
+    it "should handle Liquid templating in instructions" do
73 65
       @checker.receive([@event])
74 66
       Event.last.payload[:message].should == "Received Some Lorem Ipsum from somevalue ."
67
+      Event.last.payload[:agent].should == "WeatherAgent"
75 68
     end
76 69
 
77
-    it "should handle matchers and JSONPaths in instructions" do
70
+    it "should handle matchers and Liquid templating in instructions" do
78 71
       @checker.receive([@event])
79 72
       Event.last.payload[:subject].should == "Weather looks like someothervalue according to the forecast at 10:00 PM EST"
80 73
     end
@@ -152,11 +145,6 @@ describe Agents::EventFormattingAgent do
152 145
       @checker.should_not be_valid
153 146
     end
154 147
 
155
-    it "should validate presence of skip_agent" do
156
-      @checker.options[:skip_agent] = ""
157
-      @checker.should_not be_valid
158
-    end
159
-
160 148
     it "should validate presence of skip_created_at" do
161 149
       @checker.options[:skip_created_at] = ""
162 150
       @checker.should_not be_valid

+ 43 - 0
spec/models/agents/google_calendar_publish_agent_spec.rb

@@ -0,0 +1,43 @@
1
+require 'spec_helper'
2
+
3
+describe Agents::GoogleCalendarPublishAgent, :vcr do
4
+  before do
5
+    @valid_params = {
6
+        'expected_update_period_in_days' => "10",
7
+        'calendar_id' => 'sqv39gj35tc837gdns1g4d81cg@group.calendar.google.com',
8
+        'google' => {
9
+          'key_file' => File.dirname(__FILE__) + '/../../data_fixtures/private.key',
10
+          'key_secret' => 'notasecret',
11
+          'service_account_email' => '1029936966326-ncjd7776pcspc98hsg82gsb56t3217ef@developer.gserviceaccount.com'
12
+        }
13
+      }
14
+    @checker = Agents::GoogleCalendarPublishAgent.new(:name => "somename", :options => @valid_params)
15
+    @checker.user = users(:jane)
16
+    @checker.save!
17
+  end
18
+
19
+  describe '#receive' do
20
+    it 'should publish any payload it receives' do
21
+      event1 = Event.new
22
+      event1.agent = agents(:bob_manual_event_agent)
23
+      event1.payload = {
24
+        'message' => { 
25
+          'visibility' => 'default',
26
+          'summary' => "Awesome event",
27
+          'description' => "An example event with text. Pro tip: DateTimes are in RFC3339",
28
+          'end' => {
29
+            'dateTime' => '2014-10-02T11:00:00-05:00'
30
+          },
31
+          'start' => {
32
+            'dateTime' => '2014-10-02T10:00:00-05:00'
33
+          }
34
+        }
35
+      }
36
+      event1.save!
37
+
38
+      @checker.receive([event1])
39
+
40
+      @checker.events.count.should eq(1)
41
+    end
42
+  end
43
+end

+ 34 - 18
spec/models/agents/imap_folder_agent_spec.rb

@@ -24,7 +24,7 @@ describe Agents::ImapFolderAgent do
24 24
         end
25 25
 
26 26
         def uidvalidity
27
-          '100'
27
+          100
28 28
         end
29 29
 
30 30
         def has_attachment?
@@ -53,7 +53,15 @@ describe Agents::ImapFolderAgent do
53 53
       ]
54 54
 
55 55
       stub(@checker).each_unread_mail.returns { |yielder|
56
-        @mails.each(&yielder)
56
+        seen = @checker.lastseen
57
+        notified = @checker.notified
58
+        @mails.each_with_object(notified) { |mail|
59
+          yielder[mail, notified]
60
+          seen[mail.uidvalidity] = mail.uid
61
+        }
62
+        @checker.lastseen = seen
63
+        @checker.notified = notified
64
+        nil
57 65
       }
58 66
 
59 67
       @payloads = [
@@ -110,11 +118,19 @@ describe Agents::ImapFolderAgent do
110 118
       end
111 119
 
112 120
       it 'should validate the boolean fields' do
113
-        @checker.options['ssl'] = false
114
-        @checker.should be_valid
121
+        %w[ssl mark_as_read].each do |key|
122
+          @checker.options[key] = 1
123
+          @checker.should_not be_valid
115 124
 
116
-        @checker.options['ssl'] = 'true'
117
-        @checker.should_not be_valid
125
+          @checker.options[key] = false
126
+          @checker.should be_valid
127
+
128
+          @checker.options[key] = 'true'
129
+          @checker.should be_valid
130
+
131
+          @checker.options[key] = ''
132
+          @checker.should be_valid
133
+        end
118 134
       end
119 135
 
120 136
       it 'should validate regexp conditions' do
@@ -139,9 +155,9 @@ describe Agents::ImapFolderAgent do
139 155
     describe '#check' do
140 156
       it 'should check for mails and save memory' do
141 157
         lambda { @checker.check }.should change { Event.count }.by(2)
142
-        @checker.memory['notified'].sort.should == @mails.map(&:message_id).sort
143
-        @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
144
-          (seen[mail.uidvalidity] ||= []) << mail.uid
158
+        @checker.notified.sort.should == @mails.map(&:message_id).sort
159
+        @checker.lastseen.should == @mails.each_with_object(@checker.make_seen) { |mail, seen|
160
+          seen[mail.uidvalidity] = mail.uid
145 161
         }
146 162
 
147 163
         Event.last(2).map(&:payload) == @payloads
@@ -153,9 +169,9 @@ describe Agents::ImapFolderAgent do
153 169
         @checker.options['conditions']['to'] = 'John.Doe@*'
154 170
 
155 171
         lambda { @checker.check }.should change { Event.count }.by(1)
156
-        @checker.memory['notified'].sort.should == [@mails.first.message_id]
157
-        @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
158
-          (seen[mail.uidvalidity] ||= []) << mail.uid
172
+        @checker.notified.sort.should == [@mails.first.message_id]
173
+        @checker.lastseen.should == @mails.each_with_object(@checker.make_seen) { |mail, seen|
174
+          seen[mail.uidvalidity] = mail.uid
159 175
         }
160 176
 
161 177
         Event.last.payload.should == @payloads.first
@@ -170,9 +186,9 @@ describe Agents::ImapFolderAgent do
170 186
         )
171 187
 
172 188
         lambda { @checker.check }.should change { Event.count }.by(1)
173
-        @checker.memory['notified'].sort.should == [@mails.last.message_id]
174
-        @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
175
-          (seen[mail.uidvalidity] ||= []) << mail.uid
189
+        @checker.notified.sort.should == [@mails.last.message_id]
190
+        @checker.lastseen.should == @mails.each_with_object(@checker.make_seen) { |mail, seen|
191
+          seen[mail.uidvalidity] = mail.uid
176 192
         }
177 193
 
178 194
         Event.last.payload.should == @payloads.last.update(
@@ -208,9 +224,9 @@ describe Agents::ImapFolderAgent do
208 224
         )
209 225
 
210 226
         lambda { @checker.check }.should_not change { Event.count }
211
-        @checker.memory['notified'].sort.should == []
212
-        @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
213
-          (seen[mail.uidvalidity] ||= []) << mail.uid
227
+        @checker.notified.sort.should == []
228
+        @checker.lastseen.should == @mails.each_with_object(@checker.make_seen) { |mail, seen|
229
+          seen[mail.uidvalidity] = mail.uid
214 230
         }
215 231
       end
216 232
 

+ 74 - 17
spec/models/agents/post_agent_spec.rb

@@ -25,11 +25,25 @@ describe Agents::PostAgent do
25 25
         'somekey' => 'value'
26 26
       }
27 27
     }
28
+    @requests = 0
29
+    @sent_requests = { Net::HTTP::Get => [], Net::HTTP::Post => [], Net::HTTP::Put => [], Net::HTTP::Delete => [], Net::HTTP::Patch => [] }
28 30
 
29
-    @sent_posts = []
30
-    @sent_gets = []
31
-    stub.any_instance_of(Agents::PostAgent).post_data { |data| @sent_posts << data }
32
-    stub.any_instance_of(Agents::PostAgent).get_data { |data| @sent_gets << data }
31
+    stub.any_instance_of(Agents::PostAgent).post_data { |data, payload, type| @requests += 1; @sent_requests[type] << data }
32
+    stub.any_instance_of(Agents::PostAgent).get_data { |data, payload| @requests += 1; @sent_requests[Net::HTTP::Get] << data }
33
+  end
34
+
35
+  describe "making requests" do
36
+    it "can make requests of each type" do
37
+      { 'get' => Net::HTTP::Get, 'put' => Net::HTTP::Put,
38
+        'post' => Net::HTTP::Post, 'patch' => Net::HTTP::Patch,
39
+        'delete' => Net::HTTP::Delete }.each.with_index do |(verb, type), index|
40
+        @checker.options['method'] = verb
41
+        @checker.should be_valid
42
+        @checker.check
43
+        @requests.should == index + 1
44
+        @sent_requests[type].length.should == 1
45
+      end
46
+    end
33 47
   end
34 48
 
35 49
   describe "#receive" do
@@ -45,11 +59,11 @@ describe Agents::PostAgent do
45 59
       lambda {
46 60
         lambda {
47 61
           @checker.receive([@event, event1])
48
-        }.should change { @sent_posts.length }.by(2)
49
-      }.should_not change { @sent_gets.length }
62
+        }.should change { @sent_requests[Net::HTTP::Post].length }.by(2)
63
+      }.should_not change { @sent_requests[Net::HTTP::Get].length }
50 64
 
51
-      @sent_posts[0].should == @event.payload.merge('default' => 'value')
52
-      @sent_posts[1].should == event1.payload
65
+      @sent_requests[Net::HTTP::Post][0].should == @event.payload.merge('default' => 'value')
66
+      @sent_requests[Net::HTTP::Post][1].should == event1.payload
53 67
     end
54 68
 
55 69
     it "can make GET requests" do
@@ -58,10 +72,19 @@ describe Agents::PostAgent do
58 72
       lambda {
59 73
         lambda {
60 74
           @checker.receive([@event])
61
-        }.should change { @sent_gets.length }.by(1)
62
-      }.should_not change { @sent_posts.length }
75
+        }.should change { @sent_requests[Net::HTTP::Get].length }.by(1)
76
+      }.should_not change { @sent_requests[Net::HTTP::Post].length }
77
+
78
+      @sent_requests[Net::HTTP::Get][0].should == @event.payload.merge('default' => 'value')
79
+    end
63 80
 
64
-      @sent_gets[0].should == @event.payload.merge('default' => 'value')
81
+    it "can skip merging the incoming event when no_merge is set, but it still interpolates" do
82
+      @checker.options['no_merge'] = 'true'
83
+      @checker.options['payload'] = {
84
+        'key' => 'it said: {{ someotherkey.somekey }}'
85
+      }
86
+      @checker.receive([@event])
87
+      @sent_requests[Net::HTTP::Post].first.should == { 'key' => 'it said: value' }
65 88
     end
66 89
   end
67 90
 
@@ -69,9 +92,9 @@ describe Agents::PostAgent do
69 92
     it "sends options['payload'] as a POST request" do
70 93
       lambda {
71 94
         @checker.check
72
-      }.should change { @sent_posts.length }.by(1)
95
+      }.should change { @sent_requests[Net::HTTP::Post].length }.by(1)
73 96
 
74
-      @sent_posts[0].should == @checker.options['payload']
97
+      @sent_requests[Net::HTTP::Post][0].should == @checker.options['payload']
75 98
     end
76 99
 
77 100
     it "sends options['payload'] as a GET request" do
@@ -79,10 +102,10 @@ describe Agents::PostAgent do
79 102
       lambda {
80 103
         lambda {
81 104
           @checker.check
82
-        }.should change { @sent_gets.length }.by(1)
83
-      }.should_not change { @sent_posts.length }
105
+        }.should change { @sent_requests[Net::HTTP::Get].length }.by(1)
106
+      }.should_not change { @sent_requests[Net::HTTP::Post].length }
84 107
 
85
-      @sent_gets[0].should == @checker.options['payload']
108
+      @sent_requests[Net::HTTP::Get][0].should == @checker.options['payload']
86 109
     end
87 110
   end
88 111
 
@@ -112,7 +135,7 @@ describe Agents::PostAgent do
112 135
       @checker.should_not be_valid
113 136
     end
114 137
 
115
-    it "should validate method as post or get, defaulting to post" do
138
+    it "should validate method as post, get, put, patch, or delete, defaulting to post" do
116 139
       @checker.options['method'] = ""
117 140
       @checker.method.should == "post"
118 141
       @checker.should be_valid
@@ -125,11 +148,35 @@ describe Agents::PostAgent do
125 148
       @checker.method.should == "get"
126 149
       @checker.should be_valid
127 150
 
151
+      @checker.options['method'] = "patch"
152
+      @checker.method.should == "patch"
153
+      @checker.should be_valid
154
+
128 155
       @checker.options['method'] = "wut"
129 156
       @checker.method.should == "wut"
130 157
       @checker.should_not be_valid
131 158
     end
132 159
 
160
+    it "should validate that no_merge is 'true' or 'false', if present" do
161
+      @checker.options['no_merge'] = ""
162
+      @checker.should be_valid
163
+
164
+      @checker.options['no_merge'] = "true"
165
+      @checker.should be_valid
166
+
167
+      @checker.options['no_merge'] = "false"
168
+      @checker.should be_valid
169
+
170
+      @checker.options['no_merge'] = false
171
+      @checker.should be_valid
172
+
173
+      @checker.options['no_merge'] = true
174
+      @checker.should be_valid
175
+
176
+      @checker.options['no_merge'] = 'blarg'
177
+      @checker.should_not be_valid
178
+    end
179
+
133 180
     it "should validate payload as a hash, if present" do
134 181
       @checker.options['payload'] = ""
135 182
       @checker.should be_valid
@@ -178,7 +225,17 @@ describe Agents::PostAgent do
178 225
     it "just returns the post_uri when no params are given" do
179 226
       @checker.options['post_url'] = "http://example.com/a/path?existing_param=existing_value"
180 227
       uri = @checker.generate_uri
228
+      uri.host.should == 'example.com'
229
+      uri.scheme.should == 'http'
181 230
       uri.request_uri.should == "/a/path?existing_param=existing_value"
182 231
     end
232
+
233
+    it "interpolates when receiving a payload" do
234
+      @checker.options['post_url'] = "https://{{ domain }}/{{ variable }}?existing_param=existing_value"
235
+      uri = @checker.generate_uri({ "some_param" => "some_value", "another_param" => "another_value" }, { 'domain' => 'google.com', 'variable' => 'a_variable' })
236
+      uri.request_uri.should == "/a_variable?existing_param=existing_value&some_param=some_value&another_param=another_value"
237
+      uri.host.should == 'google.com'
238
+      uri.scheme.should == 'https'
239
+    end
183 240
   end
184 241
 end

+ 81 - 0
spec/models/agents/rss_agent_spec.rb

@@ -0,0 +1,81 @@
1
+require 'spec_helper'
2
+
3
+describe Agents::RssAgent do
4
+  before do
5
+    @valid_options = {
6
+      'expected_update_period_in_days' => "2",
7
+      'url' => "https://github.com/cantino/huginn/commits/master.atom",
8
+    }
9
+
10
+    stub_request(:any, /github.com/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/github_rss.atom")), :status => 200)
11
+  end
12
+
13
+  let(:agent) do
14
+    _agent = Agents::RssAgent.new(:name => "github rss feed", :options => @valid_options)
15
+    _agent.user = users(:bob)
16
+    _agent.save!
17
+    _agent
18
+  end
19
+
20
+  it_behaves_like WebRequestConcern
21
+
22
+  describe "validations" do
23
+    it "should validate the presence of url" do
24
+      agent.options['url'] = "http://google.com"
25
+      agent.should be_valid
26
+
27
+      agent.options['url'] = ""
28
+      agent.should_not be_valid
29
+
30
+      agent.options['url'] = nil
31
+      agent.should_not be_valid
32
+    end
33
+
34
+    it "should validate the presence and numericality of expected_update_period_in_days" do
35
+      agent.options['expected_update_period_in_days'] = "5"
36
+      agent.should be_valid
37
+
38
+      agent.options['expected_update_period_in_days'] = "wut?"
39
+      agent.should_not be_valid
40
+
41
+      agent.options['expected_update_period_in_days'] = 0
42
+      agent.should_not be_valid
43
+
44
+      agent.options['expected_update_period_in_days'] = nil
45
+      agent.should_not be_valid
46
+
47
+      agent.options['expected_update_period_in_days'] = ""
48
+      agent.should_not be_valid
49
+    end
50
+  end
51
+
52
+  describe "emitting RSS events" do
53
+    it "should emit items as events" do
54
+      lambda {
55
+        agent.check
56
+      }.should change { agent.events.count }.by(20)
57
+    end
58
+
59
+    it "should track ids and not re-emit the same item when seen again" do
60
+      agent.check
61
+      agent.memory['seen_ids'].should == agent.events.map {|e| e.payload['id'] }
62
+
63
+      newest_id = agent.memory['seen_ids'][0]
64
+      agent.events.first.payload['id'].should == newest_id
65
+      agent.memory['seen_ids'] = agent.memory['seen_ids'][1..-1] # forget the newest id
66
+
67
+      lambda {
68
+        agent.check
69
+      }.should change { agent.events.count }.by(1)
70
+
71
+      agent.events.first.payload['id'].should == newest_id
72
+      agent.memory['seen_ids'][0].should == newest_id
73
+    end
74
+
75
+    it "should truncate the seen_ids in memory at 500 items" do
76
+      agent.memory['seen_ids'] = ['x'] * 490
77
+      agent.check
78
+      agent.memory['seen_ids'].length.should == 500
79
+    end
80
+  end
81
+end

+ 85 - 60
spec/models/agents/website_agent_spec.rb

@@ -4,23 +4,25 @@ describe Agents::WebsiteAgent do
4 4
   describe "checking without basic auth" do
5 5
     before do
6 6
       stub_request(:any, /xkcd/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/xkcd.html")), :status => 200)
7
-      @site = {
7
+      @valid_options = {
8 8
         'name' => "XKCD",
9
-        'expected_update_period_in_days' => 2,
9
+        'expected_update_period_in_days' => "2",
10 10
         'type' => "html",
11 11
         'url' => "http://xkcd.com",
12 12
         'mode' => 'on_change',
13 13
         'extract' => {
14
-          'url' => { 'css' => "#comic img", 'attr' => "src" },
15
-          'title' => { 'css' => "#comic img", 'attr' => "alt" },
16
-          'hovertext' => { 'css' => "#comic img", 'attr' => "title" }
14
+          'url' => { 'css' => "#comic img", 'value' => "@src" },
15
+          'title' => { 'css' => "#comic img", 'value' => "@alt" },
16
+          'hovertext' => { 'css' => "#comic img", 'value' => "@title" }
17 17
         }
18 18
       }
19
-      @checker = Agents::WebsiteAgent.new(:name => "xkcd", :options => @site, :keep_events_for => 2)
19
+      @checker = Agents::WebsiteAgent.new(:name => "xkcd", :options => @valid_options, :keep_events_for => 2)
20 20
       @checker.user = users(:bob)
21 21
       @checker.save!
22 22
     end
23 23
 
24
+    it_behaves_like WebRequestConcern
25
+
24 26
     describe "validations" do
25 27
       before do
26 28
         @checker.should be_valid
@@ -42,20 +44,6 @@ describe Agents::WebsiteAgent do
42 44
         @checker.should be_valid
43 45
       end
44 46
 
45
-      it "should validate headers" do
46
-        @checker.options['headers'] = "blah"
47
-        @checker.should_not be_valid
48
-
49
-        @checker.options['headers'] = ""
50
-        @checker.should be_valid
51
-
52
-        @checker.options['headers'] = {}
53
-        @checker.should be_valid
54
-
55
-        @checker.options['headers'] = { 'foo' => 'bar' }
56
-        @checker.should be_valid
57
-      end
58
-
59 47
       it "should validate mode" do
60 48
         @checker.options['mode'] = "nonsense"
61 49
         @checker.should_not be_valid
@@ -97,16 +85,16 @@ describe Agents::WebsiteAgent do
97 85
 
98 86
       it "should always save events when in :all mode" do
99 87
         lambda {
100
-          @site['mode'] = 'all'
101
-          @checker.options = @site
88
+          @valid_options['mode'] = 'all'
89
+          @checker.options = @valid_options
102 90
           @checker.check
103 91
           @checker.check
104 92
         }.should change { Event.count }.by(2)
105 93
       end
106 94
 
107 95
       it "should take uniqueness_look_back into account during deduplication" do
108
-        @site['mode'] = 'all'
109
-        @checker.options = @site
96
+        @valid_options['mode'] = 'all'
97
+        @checker.options = @valid_options
110 98
         @checker.check
111 99
         @checker.check
112 100
         event = Event.last
@@ -114,47 +102,47 @@ describe Agents::WebsiteAgent do
114 102
         event.save
115 103
 
116 104
         lambda {
117
-          @site['mode'] = 'on_change'
118
-          @site['uniqueness_look_back'] = 2
119
-          @checker.options = @site
105
+          @valid_options['mode'] = 'on_change'
106
+          @valid_options['uniqueness_look_back'] = 2
107
+          @checker.options = @valid_options
120 108
           @checker.check
121 109
         }.should_not change { Event.count }
122 110
 
123 111
         lambda {
124
-          @site['mode'] = 'on_change'
125
-          @site['uniqueness_look_back'] = 1
126
-          @checker.options = @site
112
+          @valid_options['mode'] = 'on_change'
113
+          @valid_options['uniqueness_look_back'] = 1
114
+          @checker.options = @valid_options
127 115
           @checker.check
128 116
         }.should change { Event.count }.by(1)
129 117
       end
130 118
 
131 119
       it "should log an error if the number of results for a set of extraction patterns differs" do
132
-        @site['extract']['url']['css'] = "div"
133
-        @checker.options = @site
120
+        @valid_options['extract']['url']['css'] = "div"
121
+        @checker.options = @valid_options
134 122
         @checker.check
135 123
         @checker.logs.first.message.should =~ /Got an uneven number of matches/
136 124
       end
137 125
 
138 126
       it "should accept an array for url" do
139
-        @site['url'] = ["http://xkcd.com/1/", "http://xkcd.com/2/"]
140
-        @checker.options = @site
127
+        @valid_options['url'] = ["http://xkcd.com/1/", "http://xkcd.com/2/"]
128
+        @checker.options = @valid_options
141 129
         lambda { @checker.save! }.should_not raise_error;
142 130
         lambda { @checker.check }.should_not raise_error;
143 131
       end
144 132
 
145 133
       it "should parse events from all urls in array" do
146 134
         lambda {
147
-          @site['url'] = ["http://xkcd.com/", "http://xkcd.com/"]
148
-          @site['mode'] = 'all'
149
-          @checker.options = @site
135
+          @valid_options['url'] = ["http://xkcd.com/", "http://xkcd.com/"]
136
+          @valid_options['mode'] = 'all'
137
+          @checker.options = @valid_options
150 138
           @checker.check
151 139
         }.should change { Event.count }.by(2)
152 140
       end
153 141
 
154 142
       it "should follow unique rules when parsing array of urls" do
155 143
         lambda {
156
-          @site['url'] = ["http://xkcd.com/", "http://xkcd.com/"]
157
-          @checker.options = @site
144
+          @valid_options['url'] = ["http://xkcd.com/", "http://xkcd.com/"]
145
+          @checker.options = @valid_options
158 146
           @checker.check
159 147
         }.should change { Event.count }.by(1)
160 148
       end
@@ -170,7 +158,7 @@ describe Agents::WebsiteAgent do
170 158
           }, :status => 200)
171 159
         site = {
172 160
           'name' => "Some JSON Response",
173
-          'expected_update_period_in_days' => 2,
161
+          'expected_update_period_in_days' => "2",
174 162
           'type' => "json",
175 163
           'url' => "http://no-encoding.example.com",
176 164
           'mode' => 'on_change',
@@ -197,7 +185,7 @@ describe Agents::WebsiteAgent do
197 185
           }, :status => 200)
198 186
         site = {
199 187
           'name' => "Some JSON Response",
200
-          'expected_update_period_in_days' => 2,
188
+          'expected_update_period_in_days' => "2",
201 189
           'type' => "json",
202 190
           'url' => "http://wrong-encoding.example.com",
203 191
           'mode' => 'on_change',
@@ -248,11 +236,11 @@ describe Agents::WebsiteAgent do
248 236
       end
249 237
 
250 238
       it "parses XPath" do
251
-        @site['extract'].each { |key, value|
239
+        @valid_options['extract'].each { |key, value|
252 240
           value.delete('css')
253 241
           value['xpath'] = "//*[@id='comic']//img"
254 242
         }
255
-        @checker.options = @site
243
+        @checker.options = @valid_options
256 244
         @checker.check
257 245
         event = Event.last
258 246
         event.payload['url'].should == "http://imgs.xkcd.com/comics/evolving.png"
@@ -263,13 +251,12 @@ describe Agents::WebsiteAgent do
263 251
       it "should turn relative urls to absolute" do
264 252
         rel_site = {
265 253
           'name' => "XKCD",
266
-          'expected_update_period_in_days' => 2,
254
+          'expected_update_period_in_days' => "2",
267 255
           'type' => "html",
268 256
           'url' => "http://xkcd.com",
269 257
           'mode' => "on_change",
270 258
           'extract' => {
271
-            'url' => {'css' => "#topLeft a", 'attr' => "href"},
272
-            'title' => {'css' => "#topLeft a", 'text' => "true"}
259
+            'url' => {'css' => "#topLeft a", 'value' => "@href"},
273 260
           }
274 261
         }
275 262
         rel = Agents::WebsiteAgent.new(:name => "xkcd", :options => rel_site)
@@ -280,6 +267,44 @@ describe Agents::WebsiteAgent do
280 267
         event.payload['url'].should == "http://xkcd.com/about"
281 268
       end
282 269
 
270
+      it "should return an integer value if XPath evaluates to one" do
271
+        rel_site = {
272
+          'name' => "XKCD",
273
+          'expected_update_period_in_days' => 2,
274
+          'type' => "html",
275
+          'url' => "http://xkcd.com",
276
+          'mode' => "on_change",
277
+          'extract' => {
278
+            'num_links' => {'css' => "#comicLinks", 'value' => "count(./a)"}
279
+          }
280
+        }
281
+        rel = Agents::WebsiteAgent.new(:name => "xkcd", :options => rel_site)
282
+        rel.user = users(:bob)
283
+        rel.save!
284
+        rel.check
285
+        event = Event.last
286
+        event.payload['num_links'].should == "9"
287
+      end
288
+
289
+      it "should return all texts concatenated if XPath returns many text nodes" do
290
+        rel_site = {
291
+          'name' => "XKCD",
292
+          'expected_update_period_in_days' => 2,
293
+          'type' => "html",
294
+          'url' => "http://xkcd.com",
295
+          'mode' => "on_change",
296
+          'extract' => {
297
+            'slogan' => {'css' => "#slogan", 'value' => ".//text()"}
298
+          }
299
+        }
300
+        rel = Agents::WebsiteAgent.new(:name => "xkcd", :options => rel_site)
301
+        rel.user = users(:bob)
302
+        rel.save!
303
+        rel.check
304
+        event = Event.last
305
+        event.payload['slogan'].should == "A webcomic of romance, sarcasm, math, and language."
306
+      end
307
+
283 308
       describe "JSON" do
284 309
         it "works with paths" do
285 310
           json = {
@@ -291,7 +316,7 @@ describe Agents::WebsiteAgent do
291 316
           stub_request(:any, /json-site/).to_return(:body => json.to_json, :status => 200)
292 317
           site = {
293 318
             'name' => "Some JSON Response",
294
-            'expected_update_period_in_days' => 2,
319
+            'expected_update_period_in_days' => "2",
295 320
             'type' => "json",
296 321
             'url' => "http://json-site.com",
297 322
             'mode' => 'on_change',
@@ -322,7 +347,7 @@ describe Agents::WebsiteAgent do
322 347
           stub_request(:any, /json-site/).to_return(:body => json.to_json, :status => 200)
323 348
           site = {
324 349
             'name' => "Some JSON Response",
325
-            'expected_update_period_in_days' => 2,
350
+            'expected_update_period_in_days' => "2",
326 351
             'type' => "json",
327 352
             'url' => "http://json-site.com",
328 353
             'mode' => 'on_change',
@@ -358,7 +383,7 @@ describe Agents::WebsiteAgent do
358 383
           stub_request(:any, /json-site/).to_return(:body => json.to_json, :status => 200)
359 384
           site = {
360 385
             'name' => "Some JSON Response",
361
-            'expected_update_period_in_days' => 2,
386
+            'expected_update_period_in_days' => "2",
362 387
             'type' => "json",
363 388
             'url' => "http://json-site.com",
364 389
             'mode' => 'on_change'
@@ -382,7 +407,7 @@ describe Agents::WebsiteAgent do
382 407
         @event.payload = { 'url' => "http://xkcd.com" }
383 408
 
384 409
         lambda {
385
-          @checker.options = @site
410
+          @checker.options = @valid_options
386 411
           @checker.receive([@event])
387 412
         }.should change { Event.count }.by(1)
388 413
       end
@@ -394,20 +419,20 @@ describe Agents::WebsiteAgent do
394 419
       stub_request(:any, /example/).
395 420
         with(headers: { 'Authorization' => "Basic #{['user:pass'].pack('m').chomp}" }).
396 421
         to_return(:body => File.read(Rails.root.join("spec/data_fixtures/xkcd.html")), :status => 200)
397
-      @site = {
422
+      @valid_options = {
398 423
         'name' => "XKCD",
399
-        'expected_update_period_in_days' => 2,
424
+        'expected_update_period_in_days' => "2",
400 425
         'type' => "html",
401 426
         'url' => "http://www.example.com",
402 427
         'mode' => 'on_change',
403 428
         'extract' => {
404
-          'url' => { 'css' => "#comic img", 'attr' => "src" },
405
-          'title' => { 'css' => "#comic img", 'attr' => "alt" },
406
-          'hovertext' => { 'css' => "#comic img", 'attr' => "title" }
429
+          'url' => { 'css' => "#comic img", 'value' => "@src" },
430
+          'title' => { 'css' => "#comic img", 'value' => "@alt" },
431
+          'hovertext' => { 'css' => "#comic img", 'value' => "@title" }
407 432
         },
408 433
         'basic_auth' => "user:pass"
409 434
       }
410
-      @checker = Agents::WebsiteAgent.new(:name => "auth", :options => @site)
435
+      @checker = Agents::WebsiteAgent.new(:name => "auth", :options => @valid_options)
411 436
       @checker.user = users(:bob)
412 437
       @checker.save!
413 438
     end
@@ -425,18 +450,18 @@ describe Agents::WebsiteAgent do
425 450
       stub_request(:any, /example/).
426 451
         with(headers: { 'foo' => 'bar', 'user_agent' => /Faraday/ }).
427 452
         to_return(:body => File.read(Rails.root.join("spec/data_fixtures/xkcd.html")), :status => 200)
428
-      @site = {
453
+      @valid_options = {
429 454
         'name' => "XKCD",
430
-        'expected_update_period_in_days' => 2,
455
+        'expected_update_period_in_days' => "2",
431 456
         'type' => "html",
432 457
         'url' => "http://www.example.com",
433 458
         'mode' => 'on_change',
434 459
         'headers' => { 'foo' => 'bar' },
435 460
         'extract' => {
436
-          'url' => { 'css' => "#comic img", 'attr' => "src" },
461
+          'url' => { 'css' => "#comic img", 'value' => "@src" },
437 462
         }
438 463
       }
439
-      @checker = Agents::WebsiteAgent.new(:name => "ua", :options => @site)
464
+      @checker = Agents::WebsiteAgent.new(:name => "ua", :options => @valid_options)
440 465
       @checker.user = users(:bob)
441 466
       @checker.save!
442 467
     end

+ 36 - 0
spec/models/event_spec.rb

@@ -76,3 +76,39 @@ describe Event do
76 76
     end
77 77
   end
78 78
 end
79
+
80
+describe EventDrop do
81
+  def interpolate(string, event)
82
+    event.agent.interpolate_string(string, event.to_liquid)
83
+  end
84
+
85
+  before do
86
+    @event = Event.new
87
+    @event.agent = agents(:jane_weather_agent)
88
+    @event.payload = {
89
+      'title' => 'some title',
90
+      'url' => 'http://some.site.example.org/',
91
+    }
92
+    @event.save!
93
+  end
94
+
95
+  it 'should be created via Agent#to_liquid' do
96
+    @event.to_liquid.class.should be(EventDrop)
97
+  end
98
+
99
+  it 'should have attributes of its payload' do
100
+    t = '{{title}}: {{url}}'
101
+    interpolate(t, @event).should eq('some title: http://some.site.example.org/')
102
+  end
103
+
104
+  it 'should be iteratable' do
105
+    # to_liquid returns self
106
+    t = "{% for pair in to_liquid %}{{pair | join:':' }}\n{% endfor %}"
107
+    interpolate(t, @event).should eq("title:some title\nurl:http://some.site.example.org/\n")
108
+  end
109
+
110
+  it 'should have agent' do
111
+    t = '{{agent.name}}'
112
+    interpolate(t, @event).should eq('SF Weather')
113
+  end
114
+end

+ 88 - 0
spec/support/shared_examples/email_concern.rb

@@ -0,0 +1,88 @@
1
+require 'spec_helper'
2
+
3
+shared_examples_for EmailConcern do
4
+  let(:valid_options) {
5
+    {
6
+      :subject => "hello!",
7
+      :expected_receive_period_in_days => "2"
8
+    }
9
+  }
10
+
11
+  let(:agent) do
12
+    _agent = described_class.new(:name => "some email agent", :options => valid_options)
13
+    _agent.user = users(:jane)
14
+    _agent
15
+  end
16
+
17
+  describe "validations" do
18
+    it "should be valid" do
19
+      agent.should be_valid
20
+    end
21
+
22
+    it "should validate the presence of 'subject'" do
23
+      agent.options['subject'] = ''
24
+      agent.should_not be_valid
25
+
26
+      agent.options['subject'] = nil
27
+      agent.should_not be_valid
28
+    end
29
+
30
+    it "should validate the presence of 'expected_receive_period_in_days'" do
31
+      agent.options['expected_receive_period_in_days'] = ''
32
+      agent.should_not be_valid
33
+
34
+      agent.options['expected_receive_period_in_days'] = nil
35
+      agent.should_not be_valid
36
+    end
37
+
38
+    it "should validate that recipients, when provided, is one or more valid email addresses" do
39
+      agent.options['recipients'] = ''
40
+      agent.should be_valid
41
+
42
+      agent.options['recipients'] = nil
43
+      agent.should be_valid
44
+
45
+      agent.options['recipients'] = 'bob@example.com'
46
+      agent.should be_valid
47
+
48
+      agent.options['recipients'] = ['bob@example.com']
49
+      agent.should be_valid
50
+
51
+      agent.options['recipients'] = ['bob@example.com', 'jane@example.com']
52
+      agent.should be_valid
53
+
54
+      agent.options['recipients'] = ['bob@example.com', 'example.com']
55
+      agent.should_not be_valid
56
+
57
+      agent.options['recipients'] = ['hi!']
58
+      agent.should_not be_valid
59
+
60
+      agent.options['recipients'] = { :foo => "bar" }
61
+      agent.should_not be_valid
62
+
63
+      agent.options['recipients'] = "wut"
64
+      agent.should_not be_valid
65
+    end
66
+  end
67
+
68
+  describe "#recipients" do
69
+    it "defaults to the user's email address" do
70
+      agent.recipients.should == [users(:jane).email]
71
+    end
72
+
73
+    it "wraps a string with an array" do
74
+      agent.options['recipients'] = 'bob@bob.com'
75
+      agent.recipients.should == ['bob@bob.com']
76
+    end
77
+
78
+    it "handles an array" do
79
+      agent.options['recipients'] = ['bob@bob.com', 'jane@jane.com']
80
+      agent.recipients.should == ['bob@bob.com', 'jane@jane.com']
81
+    end
82
+
83
+    it "interpolates" do
84
+      agent.options['recipients'] = "{{ username }}@{{ domain }}"
85
+      agent.recipients('username' => 'bob', 'domain' => 'example.com').should == ["bob@example.com"]
86
+    end
87
+  end
88
+end

+ 5 - 5
spec/support/shared_examples/liquid_interpolatable.rb

@@ -20,7 +20,7 @@ shared_examples_for LiquidInterpolatable do
20 20
 
21 21
   describe "interpolating liquid templates" do
22 22
     it "should work" do
23
-      @checker.interpolate_options(@checker.options, @event.payload).should == {
23
+      @checker.interpolate_options(@checker.options, @event).should == {
24 24
           "normal" => "just some normal text",
25 25
           "variable" => "hello",
26 26
           "text" => "Some test with an embedded hello",
@@ -30,7 +30,7 @@ shared_examples_for LiquidInterpolatable do
30 30
 
31 31
     it "should work with arrays", focus: true do
32 32
       @checker.options = {"value" => ["{{variable}}", "Much array", "Hey, {{hello_world}}"]}
33
-      @checker.interpolate_options(@checker.options, @event.payload).should == {
33
+      @checker.interpolate_options(@checker.options, @event).should == {
34 34
         "value" => ["hello", "Much array", "Hey, Hello world"]
35 35
       }
36 36
     end
@@ -38,7 +38,7 @@ shared_examples_for LiquidInterpolatable do
38 38
     it "should work recursively" do
39 39
       @checker.options['hash'] = {'recursive' => "{{variable}}"}
40 40
       @checker.options['indifferent_hash'] = ActiveSupport::HashWithIndifferentAccess.new({'recursive' => "{{variable}}"})
41
-      @checker.interpolate_options(@checker.options, @event.payload).should == {
41
+      @checker.interpolate_options(@checker.options, @event).should == {
42 42
           "normal" => "just some normal text",
43 43
           "variable" => "hello",
44 44
           "text" => "Some test with an embedded hello",
@@ -49,8 +49,8 @@ shared_examples_for LiquidInterpolatable do
49 49
     end
50 50
 
51 51
     it "should work for strings" do
52
-      @checker.interpolate_string("{{variable}}", @event.payload).should == "hello"
53
-      @checker.interpolate_string("{{variable}} you", @event.payload).should == "hello you"
52
+      @checker.interpolate_string("{{variable}}", @event).should == "hello"
53
+      @checker.interpolate_string("{{variable}} you", @event).should == "hello you"
54 54
     end
55 55
   end
56 56
 

+ 66 - 0
spec/support/shared_examples/web_request_concern.rb

@@ -0,0 +1,66 @@
1
+require 'spec_helper'
2
+
3
+shared_examples_for WebRequestConcern do
4
+  let(:agent) do
5
+    _agent = described_class.new(:name => "some agent", :options => @valid_options || {})
6
+    _agent.user = users(:jane)
7
+    _agent
8
+  end
9
+
10
+  describe "validations" do
11
+    it "should be valid" do
12
+      agent.should be_valid
13
+    end
14
+
15
+    it "should validate user_agent" do
16
+      agent.options['user_agent'] = nil
17
+      agent.should be_valid
18
+
19
+      agent.options['user_agent'] = ""
20
+      agent.should be_valid
21
+
22
+      agent.options['user_agent'] = "foo"
23
+      agent.should be_valid
24
+
25
+      agent.options['user_agent'] = ["foo"]
26
+      agent.should_not be_valid
27
+
28
+      agent.options['user_agent'] = 1
29
+      agent.should_not be_valid
30
+    end
31
+
32
+    it "should validate headers" do
33
+      agent.options['headers'] = "blah"
34
+      agent.should_not be_valid
35
+
36
+      agent.options['headers'] = ""
37
+      agent.should be_valid
38
+
39
+      agent.options['headers'] = {}
40
+      agent.should be_valid
41
+
42
+      agent.options['headers'] = { 'foo' => 'bar' }
43
+      agent.should be_valid
44
+    end
45
+
46
+    it "should validate basic_auth" do
47
+      agent.options['basic_auth'] = "foo:bar"
48
+      agent.should be_valid
49
+
50
+      agent.options['basic_auth'] = ["foo", "bar"]
51
+      agent.should be_valid
52
+
53
+      agent.options['basic_auth'] = ""
54
+      agent.should be_valid
55
+
56
+      agent.options['basic_auth'] = nil
57
+      agent.should be_valid
58
+
59
+      agent.options['basic_auth'] = "blah"
60
+      agent.should_not be_valid
61
+
62
+      agent.options['basic_auth'] = ["blah"]
63
+      agent.should_not be_valid
64
+    end
65
+  end
66
+end

+ 9 - 0
spec/support/vcr_support.rb

@@ -0,0 +1,9 @@
1
+require 'vcr'
2
+
3
+VCR.configure do |c|
4
+  c.cassette_library_dir = 'spec/cassettes'
5
+  c.allow_http_connections_when_no_cassette = true
6
+  c.hook_into :webmock
7
+  c.default_cassette_options = { record: :new_episodes}
8
+  c.configure_rspec_metadata!
9
+end